diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 0f10a888..d6a6ed0b 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -78,4 +78,3 @@ jobs: ${{ steps.changelog.outputs.changelog }} draft: true - prerelease: true diff --git a/package.json b/package.json index 13b92ccf..385f2f5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "description": "ZenStack", "packageManager": "pnpm@10.12.1", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 1f095ebc..dbfe7223 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "type": "module", "author": { "name": "ZenStack Team" @@ -28,9 +28,9 @@ "pack": "pnpm pack" }, "dependencies": { + "@zenstackhq/common-helpers": "workspace:*", "@zenstackhq/language": "workspace:*", "@zenstackhq/sdk": "workspace:*", - "@zenstackhq/common-helpers": "workspace:*", "colors": "1.4.0", "commander": "^8.3.0", "langium": "catalog:", @@ -43,10 +43,12 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", + "@types/tmp": "^0.2.6", "@zenstackhq/eslint-config": "workspace:*", "@zenstackhq/runtime": "workspace:*", "@zenstackhq/testtools": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", - "better-sqlite3": "^11.8.1" + "better-sqlite3": "^11.8.1", + "tmp": "^0.2.3" } } diff --git a/packages/cli/src/actions/action-utils.ts b/packages/cli/src/actions/action-utils.ts index 2c736e50..9f36d53c 100644 --- a/packages/cli/src/actions/action-utils.ts +++ b/packages/cli/src/actions/action-utils.ts @@ -1,7 +1,9 @@ -import fs from 'node:fs'; -import { CliError } from '../cli-error'; import { loadDocument } from '@zenstackhq/language'; +import { PrismaSchemaGenerator } from '@zenstackhq/sdk'; import colors from 'colors'; +import fs from 'node:fs'; +import path from 'node:path'; +import { CliError } from '../cli-error'; export function getSchemaFile(file?: string) { if (file) { @@ -41,3 +43,11 @@ export function handleSubProcessError(err: unknown) { process.exit(1); } } + +export async function generateTempPrismaSchema(zmodelPath: string) { + const model = await loadSchemaDocument(zmodelPath); + const prismaSchema = await new PrismaSchemaGenerator(model).generate(); + const prismaSchemaFile = path.resolve(path.dirname(zmodelPath), '~schema.prisma'); + fs.writeFileSync(prismaSchemaFile, prismaSchema); + return prismaSchemaFile; +} diff --git a/packages/cli/src/actions/db.ts b/packages/cli/src/actions/db.ts index cde7342e..e588e8c2 100644 --- a/packages/cli/src/actions/db.ts +++ b/packages/cli/src/actions/db.ts @@ -1,43 +1,44 @@ -import path from 'node:path'; +import fs from 'node:fs'; import { execPackage } from '../utils/exec-utils'; -import { getSchemaFile, handleSubProcessError } from './action-utils'; -import { run as runGenerate } from './generate'; +import { generateTempPrismaSchema, getSchemaFile, handleSubProcessError } from './action-utils'; -type CommonOptions = { +type Options = { schema?: string; - name?: string; + acceptDataLoss?: boolean; + forceReset?: boolean; }; /** * CLI action for db related commands */ -export async function run(command: string, options: CommonOptions) { - const schemaFile = getSchemaFile(options.schema); - - // run generate first - await runGenerate({ - schema: schemaFile, - silent: true, - }); - - const prismaSchemaFile = path.join(path.dirname(schemaFile), 'schema.prisma'); - +export async function run(command: string, options: Options) { switch (command) { case 'push': - await runPush(prismaSchemaFile, options); + await runPush(options); break; } } -async function runPush(prismaSchemaFile: string, options: any) { - const cmd = `prisma db push --schema "${prismaSchemaFile}"${ - options.acceptDataLoss ? ' --accept-data-loss' : '' - }${options.forceReset ? ' --force-reset' : ''} --skip-generate`; +async function runPush(options: Options) { + // generate a temp prisma schema file + const schemaFile = getSchemaFile(options.schema); + const prismaSchemaFile = await generateTempPrismaSchema(schemaFile); + try { - await execPackage(cmd, { - stdio: 'inherit', - }); - } catch (err) { - handleSubProcessError(err); + // run prisma db push + const cmd = `prisma db push --schema "${prismaSchemaFile}"${ + options.acceptDataLoss ? ' --accept-data-loss' : '' + }${options.forceReset ? ' --force-reset' : ''} --skip-generate`; + try { + await execPackage(cmd, { + stdio: 'inherit', + }); + } catch (err) { + handleSubProcessError(err); + } + } finally { + if (fs.existsSync(prismaSchemaFile)) { + fs.unlinkSync(prismaSchemaFile); + } } } diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index a78d9b03..269f837a 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -10,6 +10,7 @@ type Options = { schema?: string; output?: string; silent?: boolean; + savePrismaSchema?: string | boolean; }; /** @@ -28,8 +29,15 @@ export async function run(options: Options) { await runPlugins(model, outputPath, tsSchemaFile); // generate Prisma schema - const prismaSchema = await new PrismaSchemaGenerator(model).generate(); - fs.writeFileSync(path.join(outputPath, 'schema.prisma'), prismaSchema); + if (options.savePrismaSchema) { + const prismaSchema = await new PrismaSchemaGenerator(model).generate(); + let prismaSchemaFile = path.join(outputPath, 'schema.prisma'); + if (typeof options.savePrismaSchema === 'string') { + prismaSchemaFile = path.resolve(outputPath, options.savePrismaSchema); + fs.mkdirSync(path.dirname(prismaSchemaFile), { recursive: true }); + } + fs.writeFileSync(prismaSchemaFile, prismaSchema); + } if (!options.silent) { console.log(colors.green('Generation completed successfully.')); diff --git a/packages/cli/src/actions/migrate.ts b/packages/cli/src/actions/migrate.ts index fd0150c9..d2bda8bb 100644 --- a/packages/cli/src/actions/migrate.ts +++ b/packages/cli/src/actions/migrate.ts @@ -1,57 +1,70 @@ -import path from 'node:path'; +import fs from 'node:fs'; import { execPackage } from '../utils/exec-utils'; -import { getSchemaFile } from './action-utils'; -import { run as runGenerate } from './generate'; +import { generateTempPrismaSchema, getSchemaFile } from './action-utils'; type CommonOptions = { schema?: string; +}; + +type DevOptions = CommonOptions & { name?: string; + createOnly?: boolean; +}; + +type ResetOptions = CommonOptions & { + force?: boolean; }; +type DeployOptions = CommonOptions; + +type StatusOptions = CommonOptions; + /** * CLI action for migration-related commands */ export async function run(command: string, options: CommonOptions) { const schemaFile = getSchemaFile(options.schema); + const prismaSchemaFile = await generateTempPrismaSchema(schemaFile); - // run generate first - await runGenerate({ - schema: schemaFile, - silent: true, - }); - - const prismaSchemaFile = path.join(path.dirname(schemaFile), 'schema.prisma'); - - switch (command) { - case 'dev': - await runDev(prismaSchemaFile, options); - break; + try { + switch (command) { + case 'dev': + await runDev(prismaSchemaFile, options as DevOptions); + break; - case 'reset': - await runReset(prismaSchemaFile, options as any); - break; + case 'reset': + await runReset(prismaSchemaFile, options as ResetOptions); + break; - case 'deploy': - await runDeploy(prismaSchemaFile, options); - break; + case 'deploy': + await runDeploy(prismaSchemaFile, options as DeployOptions); + break; - case 'status': - await runStatus(prismaSchemaFile, options); - break; + case 'status': + await runStatus(prismaSchemaFile, options as StatusOptions); + break; + } + } finally { + if (fs.existsSync(prismaSchemaFile)) { + fs.unlinkSync(prismaSchemaFile); + } } } -async function runDev(prismaSchemaFile: string, _options: unknown) { +async function runDev(prismaSchemaFile: string, options: DevOptions) { try { - await execPackage(`prisma migrate dev --schema "${prismaSchemaFile}" --skip-generate`, { - stdio: 'inherit', - }); + await execPackage( + `prisma migrate dev --schema "${prismaSchemaFile}" --skip-generate${options.name ? ` --name ${options.name}` : ''}${options.createOnly ? ' --create-only' : ''}`, + { + stdio: 'inherit', + }, + ); } catch (err) { handleSubProcessError(err); } } -async function runReset(prismaSchemaFile: string, options: { force: boolean }) { +async function runReset(prismaSchemaFile: string, options: ResetOptions) { try { await execPackage(`prisma migrate reset --schema "${prismaSchemaFile}"${options.force ? ' --force' : ''}`, { stdio: 'inherit', @@ -61,7 +74,7 @@ async function runReset(prismaSchemaFile: string, options: { force: boolean }) { } } -async function runDeploy(prismaSchemaFile: string, _options: unknown) { +async function runDeploy(prismaSchemaFile: string, _options: DeployOptions) { try { await execPackage(`prisma migrate deploy --schema "${prismaSchemaFile}"`, { stdio: 'inherit', @@ -71,7 +84,7 @@ async function runDeploy(prismaSchemaFile: string, _options: unknown) { } } -async function runStatus(prismaSchemaFile: string, _options: unknown) { +async function runStatus(prismaSchemaFile: string, _options: StatusOptions) { try { await execPackage(`prisma migrate status --schema "${prismaSchemaFile}"`, { stdio: 'inherit', diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 24c64b8b..61013bfe 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -49,6 +49,13 @@ export function createProgram() { .command('generate') .description('Run code generation.') .addOption(schemaOption) + .addOption(new Option('--silent', 'do not print any output')) + .addOption( + new Option( + '--save-prisma-schema [path]', + 'save a Prisma schema file, by default into the output directory', + ), + ) .addOption(new Option('-o, --output ', 'default output directory for core plugins')) .action(generateAction); diff --git a/packages/cli/test/db.test.ts b/packages/cli/test/db.test.ts new file mode 100644 index 00000000..162d09d6 --- /dev/null +++ b/packages/cli/test/db.test.ts @@ -0,0 +1,18 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createProject, runCli } from './utils'; + +const model = ` +model User { + id String @id @default(cuid()) +} +`; + +describe('CLI db commands test', () => { + it('should generate a database with db push', () => { + const workDir = createProject(model); + runCli('db push', workDir); + expect(fs.existsSync(path.join(workDir, 'zenstack/dev.db'))).toBe(true); + }); +}); diff --git a/packages/cli/test/generate.test.ts b/packages/cli/test/generate.test.ts new file mode 100644 index 00000000..4372b869 --- /dev/null +++ b/packages/cli/test/generate.test.ts @@ -0,0 +1,44 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createProject, runCli } from './utils'; + +const model = ` +model User { + id String @id @default(cuid()) +} +`; + +describe('CLI generate command test', () => { + it('should generate a TypeScript schema', () => { + const workDir = createProject(model); + runCli('generate', workDir); + expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true); + expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(false); + }); + + it('should respect custom output directory', () => { + const workDir = createProject(model); + runCli('generate --output ./zen', workDir); + expect(fs.existsSync(path.join(workDir, 'zen/schema.ts'))).toBe(true); + }); + + it('should respect custom schema location', () => { + const workDir = createProject(model); + fs.renameSync(path.join(workDir, 'zenstack/schema.zmodel'), path.join(workDir, 'zenstack/foo.zmodel')); + runCli('generate --schema ./zenstack/foo.zmodel', workDir); + expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true); + }); + + it('should respect save prisma schema option', () => { + const workDir = createProject(model); + runCli('generate --save-prisma-schema', workDir); + expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(true); + }); + + it('should respect save prisma schema custom path option', () => { + const workDir = createProject(model); + runCli('generate --save-prisma-schema "../prisma/schema.prisma"', workDir); + expect(fs.existsSync(path.join(workDir, 'prisma/schema.prisma'))).toBe(true); + }); +}); diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts new file mode 100644 index 00000000..947db005 --- /dev/null +++ b/packages/cli/test/init.test.ts @@ -0,0 +1,13 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import tmp from 'tmp'; +import { describe, expect, it } from 'vitest'; +import { runCli } from './utils'; + +describe('Cli init command tests', () => { + it('should create a new project', () => { + const { name: workDir } = tmp.dirSync({ unsafeCleanup: true }); + runCli('init', workDir); + expect(fs.existsSync(path.join(workDir, 'zenstack/schema.zmodel'))).toBe(true); + }); +}); diff --git a/packages/cli/test/migrate.test.ts b/packages/cli/test/migrate.test.ts new file mode 100644 index 00000000..85c2a928 --- /dev/null +++ b/packages/cli/test/migrate.test.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createProject, runCli } from './utils'; + +const model = ` +model User { + id String @id @default(cuid()) +} +`; + +describe('CLI migrate commands test', () => { + it('should generate a database with migrate dev', () => { + const workDir = createProject(model); + runCli('migrate dev --name init', workDir); + expect(fs.existsSync(path.join(workDir, 'zenstack/dev.db'))).toBe(true); + expect(fs.existsSync(path.join(workDir, 'zenstack/migrations'))).toBe(true); + }); + + it('should reset the database with migrate reset', () => { + const workDir = createProject(model); + runCli('db push', workDir); + expect(fs.existsSync(path.join(workDir, 'zenstack/dev.db'))).toBe(true); + runCli('migrate reset --force', workDir); + expect(fs.existsSync(path.join(workDir, 'zenstack/dev.db'))).toBe(true); + }); + + it('should reset the database with migrate deploy', () => { + const workDir = createProject(model); + runCli('migrate dev --name init', workDir); + fs.rmSync(path.join(workDir, 'zenstack/dev.db')); + runCli('migrate deploy', workDir); + expect(fs.existsSync(path.join(workDir, 'zenstack/dev.db'))).toBe(true); + }); + + it('supports migrate status', () => { + const workDir = createProject(model); + runCli('migrate dev --name init', workDir); + runCli('migrate status', workDir); + }); +}); diff --git a/packages/cli/test/utils.ts b/packages/cli/test/utils.ts new file mode 100644 index 00000000..2fafb207 --- /dev/null +++ b/packages/cli/test/utils.ts @@ -0,0 +1,23 @@ +import { createTestProject } from '@zenstackhq/testtools'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const ZMODEL_PRELUDE = `datasource db { + provider = "sqlite" + url = "file:./dev.db" +} +`; + +export function createProject(zmodel: string, addPrelude = true) { + const workDir = createTestProject(); + fs.mkdirSync(path.join(workDir, 'zenstack'), { recursive: true }); + const schemaPath = path.join(workDir, 'zenstack/schema.zmodel'); + fs.writeFileSync(schemaPath, addPrelude ? `${ZMODEL_PRELUDE}\n\n${zmodel}` : zmodel); + return workDir; +} + +export function runCli(command: string, cwd: string) { + const cli = path.join(__dirname, '../dist/index.js'); + execSync(`node ${cli} ${command}`, { cwd }); +} diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index da866f6a..a1c267cb 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index ea1b6143..77e7f85f 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 555a07bf..52fa2cec 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "type": "module", "private": true, "license": "MIT" diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 935a600b..d104baec 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack", "publisher": "zenstack", - "version": "3.0.3", + "version": "3.0.0-alpha.7", "displayName": "ZenStack Language Tools", "description": "VSCode extension for ZenStack ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index 8ab6c7de..631726e8 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/language", "description": "ZenStack ZModel language specification", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "license": "MIT", "author": "ZenStack Team", "files": [ diff --git a/packages/runtime/package.json b/packages/runtime/package.json index c9a1d459..8cf17f83 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/runtime", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "description": "ZenStack Runtime", "type": "module", "scripts": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 869294d6..cb9ffea7 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/sdk/src/schema/schema.ts b/packages/sdk/src/schema/schema.ts index d6242e68..8ef02fb6 100644 --- a/packages/sdk/src/schema/schema.ts +++ b/packages/sdk/src/schema/schema.ts @@ -59,7 +59,7 @@ export type FieldDef = { unique?: boolean; updatedAt?: boolean; attributes?: AttributeApplication[]; - default?: MappedBuiltinType | Expression; + default?: MappedBuiltinType | Expression | unknown[]; relation?: RelationInfo; foreignKeyFor?: string[]; computed?: boolean; diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index c6e22a2e..c1cc020b 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -284,12 +284,7 @@ export class TsSchemaGenerator { } private createDataModelFieldObject(field: DataModelField) { - const objectFields = [ - ts.factory.createPropertyAssignment( - 'type', - ts.factory.createStringLiteral(field.type.type ?? field.type.reference!.$refText), - ), - ]; + const objectFields = [ts.factory.createPropertyAssignment('type', this.generateFieldTypeLiteral(field))]; if (isIdField(field)) { objectFields.push(ts.factory.createPropertyAssignment('id', ts.factory.createTrue())); @@ -323,9 +318,9 @@ export class TsSchemaGenerator { ); } - const defaultValue = this.getMappedDefault(field); + const defaultValue = this.getFieldMappedDefault(field); if (defaultValue !== undefined) { - if (typeof defaultValue === 'object') { + if (typeof defaultValue === 'object' && !Array.isArray(defaultValue)) { if ('call' in defaultValue) { objectFields.push( ts.factory.createPropertyAssignment( @@ -371,18 +366,20 @@ export class TsSchemaGenerator { throw new Error(`Unsupported default value type for field ${field.name}`); } } else { - objectFields.push( - ts.factory.createPropertyAssignment( - 'default', - typeof defaultValue === 'string' - ? ts.factory.createStringLiteral(defaultValue) - : typeof defaultValue === 'number' - ? ts.factory.createNumericLiteral(defaultValue) - : defaultValue === true - ? ts.factory.createTrue() - : ts.factory.createFalse(), - ), - ); + if (Array.isArray(defaultValue)) { + objectFields.push( + ts.factory.createPropertyAssignment( + 'default', + ts.factory.createArrayLiteralExpression( + defaultValue.map((item) => this.createLiteralNode(item as any)), + ), + ), + ); + } else { + objectFields.push( + ts.factory.createPropertyAssignment('default', this.createLiteralNode(defaultValue)), + ); + } } } @@ -438,37 +435,44 @@ export class TsSchemaGenerator { } } - private getMappedDefault( + private getFieldMappedDefault( field: DataModelField, - ): string | number | boolean | { call: string; args: any[] } | { authMember: string[] } | undefined { + ): string | number | boolean | unknown[] | { call: string; args: any[] } | { authMember: string[] } | undefined { const defaultAttr = getAttribute(field, '@default'); if (!defaultAttr) { return undefined; } - const defaultValue = defaultAttr.args[0]?.value; invariant(defaultValue, 'Expected a default value'); + return this.getMappedValue(defaultValue, field.type); + } - if (isLiteralExpr(defaultValue)) { - const lit = (defaultValue as LiteralExpr).value; - return field.type.type === 'Boolean' + private getMappedValue( + expr: Expression, + fieldType: DataModelFieldType, + ): string | number | boolean | unknown[] | { call: string; args: any[] } | { authMember: string[] } | undefined { + if (isLiteralExpr(expr)) { + const lit = (expr as LiteralExpr).value; + return fieldType.type === 'Boolean' ? (lit as boolean) - : ['Int', 'Float', 'Decimal', 'BigInt'].includes(field.type.type!) + : ['Int', 'Float', 'Decimal', 'BigInt'].includes(fieldType.type!) ? Number(lit) : lit; - } else if (isReferenceExpr(defaultValue) && isEnumField(defaultValue.target.ref)) { - return defaultValue.target.ref.name; - } else if (isInvocationExpr(defaultValue)) { + } else if (isArrayExpr(expr)) { + return expr.items.map((item) => this.getMappedValue(item, fieldType)); + } else if (isReferenceExpr(expr) && isEnumField(expr.target.ref)) { + return expr.target.ref.name; + } else if (isInvocationExpr(expr)) { return { - call: defaultValue.function.$refText, - args: defaultValue.args.map((arg) => this.getLiteral(arg.value)), + call: expr.function.$refText, + args: expr.args.map((arg) => this.getLiteral(arg.value)), }; - } else if (this.isAuthMemberAccess(defaultValue)) { + } else if (this.isAuthMemberAccess(expr)) { return { - authMember: this.getMemberAccessChain(defaultValue), + authMember: this.getMemberAccessChain(expr), }; } else { - throw new Error(`Unsupported default value type for field ${field.name}`); + throw new Error(`Unsupported default value type for ${expr.$type}`); } } @@ -618,10 +622,7 @@ export class TsSchemaGenerator { ts.factory.createPropertyAssignment( field.name, ts.factory.createObjectLiteralExpression([ - ts.factory.createPropertyAssignment( - 'type', - ts.factory.createStringLiteral(field.type.type!), - ), + ts.factory.createPropertyAssignment('type', this.generateFieldTypeLiteral(field)), ]), ), ); @@ -629,6 +630,10 @@ export class TsSchemaGenerator { } // model-level id and unique + + // it's possible to have the same set of fields in both `@@id` and `@@unique` + // so we need to deduplicate them + const seenKeys = new Set(); for (const attr of dm.attributes) { if (attr.decl.$refText === '@@id' || attr.decl.$refText === '@@unique') { const fieldNames = this.getReferenceNames(attr.args[0]!.value); @@ -643,15 +648,17 @@ export class TsSchemaGenerator { ts.factory.createPropertyAssignment( fieldNames[0]!, ts.factory.createObjectLiteralExpression([ - ts.factory.createPropertyAssignment( - 'type', - ts.factory.createStringLiteral(fieldDef.type.type!), - ), + ts.factory.createPropertyAssignment('type', this.generateFieldTypeLiteral(fieldDef)), ]), ), ); } else { // multi-field unique + const key = fieldNames.join('_'); + if (seenKeys.has(key)) { + continue; + } + seenKeys.add(key); properties.push( ts.factory.createPropertyAssignment( fieldNames.join('_'), @@ -663,7 +670,7 @@ export class TsSchemaGenerator { ts.factory.createObjectLiteralExpression([ ts.factory.createPropertyAssignment( 'type', - ts.factory.createStringLiteral(fieldDef.type.type!), + this.generateFieldTypeLiteral(fieldDef), ), ]), ); @@ -678,6 +685,19 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(properties, true); } + private generateFieldTypeLiteral(field: DataModelField): ts.Expression { + invariant( + field.type.type || field.type.reference || field.type.unsupported, + 'Field type must be a primitive, reference, or Unsupported', + ); + + return field.type.type + ? ts.factory.createStringLiteral(field.type.type) + : field.type.reference + ? ts.factory.createStringLiteral(field.type.reference.$refText) + : ts.factory.createStringLiteral('unknown'); + } + private createEnumObject(e: Enum) { return ts.factory.createObjectLiteralExpression( e.fields.map((field) => diff --git a/packages/tanstack-query/package.json b/packages/tanstack-query/package.json index d093511e..1a2b23a0 100644 --- a/packages/tanstack-query/package.json +++ b/packages/tanstack-query/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tanstack-query", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "description": "", "main": "index.js", "type": "module", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 7918e2fe..ee6cb091 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/testtools/src/index.ts b/packages/testtools/src/index.ts index e27a6e2f..dd917ab1 100644 --- a/packages/testtools/src/index.ts +++ b/packages/testtools/src/index.ts @@ -1 +1,2 @@ +export * from './project'; export * from './schema'; diff --git a/packages/testtools/src/project.ts b/packages/testtools/src/project.ts new file mode 100644 index 00000000..c3753cfb --- /dev/null +++ b/packages/testtools/src/project.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import tmp from 'tmp'; + +export function createTestProject() { + const { name: workDir } = tmp.dirSync({ unsafeCleanup: true }); + + fs.mkdirSync(path.join(workDir, 'node_modules')); + + // symlink all entries from "node_modules" + const nodeModules = fs.readdirSync(path.join(__dirname, '../node_modules')); + for (const entry of nodeModules) { + if (entry.startsWith('@zenstackhq')) { + continue; + } + fs.symlinkSync( + path.join(__dirname, '../node_modules', entry), + path.join(workDir, 'node_modules', entry), + 'dir', + ); + } + + // in addition, symlink zenstack packages + const zenstackPackages = ['language', 'sdk', 'runtime', 'cli']; + fs.mkdirSync(path.join(workDir, 'node_modules/@zenstackhq')); + for (const pkg of zenstackPackages) { + fs.symlinkSync( + path.join(__dirname, `../../${pkg}`), + path.join(workDir, `node_modules/@zenstackhq/${pkg}`), + 'dir', + ); + } + + fs.writeFileSync( + path.join(workDir, 'package.json'), + JSON.stringify( + { + name: 'test', + version: '1.0.0', + type: 'module', + }, + null, + 4, + ), + ); + + fs.writeFileSync( + path.join(workDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + module: 'ESNext', + target: 'ESNext', + moduleResolution: 'Bundler', + esModuleInterop: true, + skipLibCheck: true, + strict: true, + }, + include: ['**/*.ts'], + }, + null, + 4, + ), + ); + + return workDir; +} diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index 37b328bf..46b733ea 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -4,8 +4,8 @@ import { glob } from 'glob'; import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import tmp from 'tmp'; import { match } from 'ts-pattern'; +import { createTestProject } from './project'; function makePrelude(provider: 'sqlite' | 'postgresql', dbName?: string) { return match(provider) @@ -34,7 +34,7 @@ export async function generateTsSchema( dbName?: string, extraSourceFiles?: Record, ) { - const { name: workDir } = tmp.dirSync({ unsafeCleanup: true }); + const workDir = createTestProject(); console.log(`Working directory: ${workDir}`); const zmodelPath = path.join(workDir, 'schema.zmodel'); @@ -47,56 +47,6 @@ export async function generateTsSchema( const tsPath = path.join(workDir, 'schema.ts'); await generator.generate(zmodelPath, pluginModelFiles, tsPath); - fs.mkdirSync(path.join(workDir, 'node_modules')); - - // symlink all entries from "node_modules" - const nodeModules = fs.readdirSync(path.join(__dirname, '../node_modules')); - for (const entry of nodeModules) { - if (entry.startsWith('@zenstackhq')) { - continue; - } - fs.symlinkSync( - path.join(__dirname, '../node_modules', entry), - path.join(workDir, 'node_modules', entry), - 'dir', - ); - } - - // in addition, symlink zenstack packages - const zenstackPackages = ['language', 'sdk', 'runtime']; - fs.mkdirSync(path.join(workDir, 'node_modules/@zenstackhq')); - for (const pkg of zenstackPackages) { - fs.symlinkSync( - path.join(__dirname, `../../${pkg}/dist`), - path.join(workDir, `node_modules/@zenstackhq/${pkg}`), - 'dir', - ); - } - - fs.writeFileSync( - path.join(workDir, 'package.json'), - JSON.stringify({ - name: 'test', - version: '1.0.0', - type: 'module', - }), - ); - - fs.writeFileSync( - path.join(workDir, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { - module: 'ESNext', - target: 'ESNext', - moduleResolution: 'Bundler', - esModuleInterop: true, - skipLibCheck: true, - strict: true, - }, - include: ['**/*.ts'], - }), - ); - if (extraSourceFiles) { for (const [fileName, content] of Object.entries(extraSourceFiles)) { const filePath = path.resolve(workDir, `${fileName}.ts`); diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 07f149b0..24db35c1 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "private": true, "license": "MIT" } diff --git a/packages/zod/package.json b/packages/zod/package.json index 0ab2e3d1..ba0486e3 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "description": "", "type": "module", "main": "index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b817b6f5..a0be9341 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,9 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@types/tmp': + specifier: ^0.2.6 + version: 0.2.6 '@zenstackhq/eslint-config': specifier: workspace:* version: link:../eslint-config @@ -120,6 +123,18 @@ importers: better-sqlite3: specifier: ^11.8.1 version: 11.8.1 + tmp: + specifier: ^0.2.3 + version: 0.2.3 + + packages/cli/test: + devDependencies: + '@types/tmp': + specifier: ^0.2.6 + version: 0.2.6 + tmp: + specifier: ^0.2.3 + version: 0.2.3 packages/common-helpers: devDependencies: @@ -392,8 +407,14 @@ importers: specifier: workspace:* version: link:../../packages/typescript-config prisma: - specifier: ^6.0.0 - version: 6.5.0(typescript@5.8.3) + specifier: 'catalog:' + version: 6.9.0(typescript@5.8.3) + + tests/e2e: + dependencies: + '@zenstackhq/testtools': + specifier: workspace:* + version: link:../../packages/testtools packages: @@ -813,39 +834,21 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@prisma/config@6.5.0': - resolution: {integrity: sha512-sOH/2Go9Zer67DNFLZk6pYOHj+rumSb0VILgltkoxOjYnlLqUpHPAN826vnx8HigqnOCxj9LRhT6U7uLiIIWgw==} - '@prisma/config@6.9.0': resolution: {integrity: sha512-Wcfk8/lN3WRJd5w4jmNQkUwhUw0eksaU/+BlAJwPQKW10k0h0LC9PD/6TQFmqKVbHQL0vG2z266r0S1MPzzhbA==} - '@prisma/debug@6.5.0': - resolution: {integrity: sha512-fc/nusYBlJMzDmDepdUtH9aBsJrda2JNErP9AzuHbgUEQY0/9zQYZdNlXmKoIWENtio+qarPNe/+DQtrX5kMcQ==} - '@prisma/debug@6.9.0': resolution: {integrity: sha512-bFeur/qi/Q+Mqk4JdQ3R38upSYPebv5aOyD1RKywVD+rAMLtRkmTFn28ZuTtVOnZHEdtxnNOCH+bPIeSGz1+Fg==} - '@prisma/engines-version@6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60': - resolution: {integrity: sha512-iK3EmiVGFDCmXjSpdsKGNqy9hOdLnvYBrJB61far/oP03hlIxrb04OWmDjNTwtmZ3UZdA5MCvI+f+3k2jPTflQ==} - '@prisma/engines-version@6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e': resolution: {integrity: sha512-Qp9gMoBHgqhKlrvumZWujmuD7q4DV/gooEyPCLtbkc13EZdSz2RsGUJ5mHb3RJgAbk+dm6XenqG7obJEhXcJ6Q==} - '@prisma/engines@6.5.0': - resolution: {integrity: sha512-FVPQYHgOllJklN9DUyujXvh3hFJCY0NX86sDmBErLvoZjy2OXGiZ5FNf3J/C4/RZZmCypZBYpBKEhx7b7rEsdw==} - '@prisma/engines@6.9.0': resolution: {integrity: sha512-im0X0bwDLA0244CDf8fuvnLuCQcBBdAGgr+ByvGfQY9wWl6EA+kRGwVk8ZIpG65rnlOwtaWIr/ZcEU5pNVvq9g==} - '@prisma/fetch-engine@6.5.0': - resolution: {integrity: sha512-3LhYA+FXP6pqY8FLHCjewyE8pGXXJ7BxZw2rhPq+CZAhvflVzq4K8Qly3OrmOkn6wGlz79nyLQdknyCG2HBTuA==} - '@prisma/fetch-engine@6.9.0': resolution: {integrity: sha512-PMKhJdl4fOdeE3J3NkcWZ+tf3W6rx3ht/rLU8w4SXFRcLhd5+3VcqY4Kslpdm8osca4ej3gTfB3+cSk5pGxgFg==} - '@prisma/get-platform@6.5.0': - resolution: {integrity: sha512-xYcvyJwNMg2eDptBYFqFLUCfgi+wZLcj6HDMsj0Qw0irvauG4IKmkbywnqwok0B+k+W+p+jThM2DKTSmoPCkzw==} - '@prisma/get-platform@6.9.0': resolution: {integrity: sha512-/B4n+5V1LI/1JQcHp+sUpyRT1bBgZVPHbsC4lt4/19Xp4jvNIVcq5KYNtQDk5e/ukTSjo9PZVAxxy9ieFtlpTQ==} @@ -1398,11 +1401,6 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - esbuild-register@3.6.0: - resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} - peerDependencies: - esbuild: '>=0.12 <1' - esbuild@0.23.1: resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} engines: {node: '>=18'} @@ -2043,16 +2041,6 @@ packages: engines: {node: '>=14'} hasBin: true - prisma@6.5.0: - resolution: {integrity: sha512-yUGXmWqv5F4PByMSNbYFxke/WbnyTLjnJ5bKr8fLkcnY7U5rU9rUTh/+Fja+gOrRxEgtCbCtca94IeITj4j/pg==} - engines: {node: '>=18.18'} - hasBin: true - peerDependencies: - typescript: '>=5.1.0' - peerDependenciesMeta: - typescript: - optional: true - prisma@6.9.0: resolution: {integrity: sha512-resJAwMyZREC/I40LF6FZ6rZTnlrlrYrb63oW37Gq+U+9xHwbyMSPJjKtM7VZf3gTO86t/Oyz+YeSXr3CmAY1Q==} engines: {node: '>=18.18'} @@ -2807,32 +2795,14 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@prisma/config@6.5.0': - dependencies: - esbuild: 0.25.5 - esbuild-register: 3.6.0(esbuild@0.25.5) - transitivePeerDependencies: - - supports-color - '@prisma/config@6.9.0': dependencies: jiti: 2.4.2 - '@prisma/debug@6.5.0': {} - '@prisma/debug@6.9.0': {} - '@prisma/engines-version@6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60': {} - '@prisma/engines-version@6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e': {} - '@prisma/engines@6.5.0': - dependencies: - '@prisma/debug': 6.5.0 - '@prisma/engines-version': 6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60 - '@prisma/fetch-engine': 6.5.0 - '@prisma/get-platform': 6.5.0 - '@prisma/engines@6.9.0': dependencies: '@prisma/debug': 6.9.0 @@ -2840,22 +2810,12 @@ snapshots: '@prisma/fetch-engine': 6.9.0 '@prisma/get-platform': 6.9.0 - '@prisma/fetch-engine@6.5.0': - dependencies: - '@prisma/debug': 6.5.0 - '@prisma/engines-version': 6.5.0-73.173f8d54f8d52e692c7e27e72a88314ec7aeff60 - '@prisma/get-platform': 6.5.0 - '@prisma/fetch-engine@6.9.0': dependencies: '@prisma/debug': 6.9.0 '@prisma/engines-version': 6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e '@prisma/get-platform': 6.9.0 - '@prisma/get-platform@6.5.0': - dependencies: - '@prisma/debug': 6.5.0 - '@prisma/get-platform@6.9.0': dependencies: '@prisma/debug': 6.9.0 @@ -3371,13 +3331,6 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild-register@3.6.0(esbuild@0.25.5): - dependencies: - debug: 4.4.1 - esbuild: 0.25.5 - transitivePeerDependencies: - - supports-color - esbuild@0.23.1: optionalDependencies: '@esbuild/aix-ppc64': 0.23.1 @@ -4050,16 +4003,6 @@ snapshots: prettier@3.5.3: {} - prisma@6.5.0(typescript@5.8.3): - dependencies: - '@prisma/config': 6.5.0 - '@prisma/engines': 6.5.0 - optionalDependencies: - fsevents: 2.3.3 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - prisma@6.9.0(typescript@5.8.3): dependencies: '@prisma/config': 6.9.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 46f0618c..50ebbc00 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - packages/** - packages/ide/** - samples/** + - tests/** catalog: kysely: ^0.27.6 zod: ^3.25.67 diff --git a/samples/blog/package.json b/samples/blog/package.json index a840303f..7a740480 100644 --- a/samples/blog/package.json +++ b/samples/blog/package.json @@ -1,6 +1,6 @@ { "name": "sample-blog", - "version": "3.0.0-alpha.6", + "version": "3.0.0-alpha.7", "description": "", "main": "index.js", "scripts": { @@ -18,7 +18,7 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", - "prisma": "^6.0.0", + "prisma": "catalog:", "@zenstackhq/cli": "workspace:*", "@zenstackhq/typescript-config": "workspace:*" } diff --git a/tests/e2e/cal.com/cal-com.test.ts b/tests/e2e/cal.com/cal-com.test.ts new file mode 100644 index 00000000..84290d5f --- /dev/null +++ b/tests/e2e/cal.com/cal-com.test.ts @@ -0,0 +1,12 @@ +import { generateTsSchema } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +describe('Cal.com e2e tests', () => { + it('has a working schema', async () => { + await expect( + generateTsSchema(fs.readFileSync(path.join(__dirname, 'schema.zmodel'), 'utf8'), 'postgresql', 'cal-com'), + ).resolves.toBeTruthy(); + }); +}); diff --git a/tests/e2e/cal.com/schema.zmodel b/tests/e2e/cal.com/schema.zmodel new file mode 100644 index 00000000..833ea37a --- /dev/null +++ b/tests/e2e/cal.com/schema.zmodel @@ -0,0 +1,2365 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DATABASE_DIRECT_URL") +} + +enum SchedulingType { + ROUND_ROBIN @map("roundRobin") + COLLECTIVE @map("collective") + MANAGED @map("managed") +} + +enum PeriodType { + UNLIMITED @map("unlimited") + ROLLING @map("rolling") + ROLLING_WINDOW @map("rolling_window") + RANGE @map("range") +} + +enum CreationSource { + API_V1 @map("api_v1") + API_V2 @map("api_v2") + WEBAPP @map("webapp") +} + +model Host { + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int + isFixed Boolean @default(false) + priority Int? + weight Int? + // weightAdjustment is deprecated. We not calculate the calibratino value on the spot. Plan to drop this column. + weightAdjustment Int? + schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + createdAt DateTime @default(now()) + + @@id([userId, eventTypeId]) + @@index([userId]) + @@index([eventTypeId]) + @@index([scheduleId]) +} + +model CalVideoSettings { + eventTypeId Int @id + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + + disableRecordingForOrganizer Boolean @default(false) + disableRecordingForGuests Boolean @default(false) + enableAutomaticTranscription Boolean @default(false) + redirectUrlOnExit String? + disableTranscriptionForGuests Boolean @default(false) + disableTranscriptionForOrganizer Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model EventType { + id Int @id @default(autoincrement()) + /// @zod.min(1) + title String + /// @zod.custom(imports.eventTypeSlug) + slug String + description String? + interfaceLanguage String? + position Int @default(0) + /// @zod.custom(imports.eventTypeLocations) + locations Json? + /// @zod.min(1) + length Int + offsetStart Int @default(0) + hidden Boolean @default(false) + hosts Host[] + users User[] @relation("user_eventtype") + owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade) + userId Int? + + profileId Int? + profile Profile? @relation(fields: [profileId], references: [id]) + + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + hashedLink HashedLink[] + bookings Booking[] + availability Availability[] + webhooks Webhook[] + destinationCalendar DestinationCalendar? + useEventLevelSelectedCalendars Boolean @default(false) + eventName String? + customInputs EventTypeCustomInput[] + parentId Int? + parent EventType? @relation("managed_eventtype", fields: [parentId], references: [id], onDelete: Cascade) + children EventType[] @relation("managed_eventtype") + /// @zod.custom(imports.eventTypeBookingFields) + bookingFields Json? + timeZone String? + periodType PeriodType @default(UNLIMITED) + /// @zod.custom(imports.coerceToDate) + periodStartDate DateTime? + /// @zod.custom(imports.coerceToDate) + periodEndDate DateTime? + periodDays Int? + periodCountCalendarDays Boolean? + lockTimeZoneToggleOnBookingPage Boolean @default(false) + requiresConfirmation Boolean @default(false) + requiresConfirmationWillBlockSlot Boolean @default(false) + requiresConfirmationForFreeEmail Boolean @default(false) + requiresBookerEmailVerification Boolean @default(false) + canSendCalVideoTranscriptionEmails Boolean @default(true) + + autoTranslateDescriptionEnabled Boolean @default(false) + /// @zod.custom(imports.recurringEventType) + recurringEvent Json? + disableGuests Boolean @default(false) + hideCalendarNotes Boolean @default(false) + hideCalendarEventDetails Boolean @default(false) + /// @zod.min(0) + minimumBookingNotice Int @default(120) + beforeEventBuffer Int @default(0) + afterEventBuffer Int @default(0) + seatsPerTimeSlot Int? + onlyShowFirstAvailableSlot Boolean @default(false) + disableCancelling Boolean? @default(false) + disableRescheduling Boolean? @default(false) + seatsShowAttendees Boolean? @default(false) + seatsShowAvailabilityCount Boolean? @default(true) + schedulingType SchedulingType? + schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + allowReschedulingCancelledBookings Boolean? @default(false) + // price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column. + price Int @default(0) + // currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column. + currency String @default("usd") + slotInterval Int? + /// @zod.custom(imports.EventTypeMetaDataSchema) + metadata Json? + /// @zod.custom(imports.successRedirectUrl) + successRedirectUrl String? + forwardParamsSuccessRedirect Boolean? @default(true) + workflows WorkflowsOnEventTypes[] + /// @zod.custom(imports.intervalLimitsType) + bookingLimits Json? + /// @zod.custom(imports.intervalLimitsType) + durationLimits Json? + isInstantEvent Boolean @default(false) + instantMeetingExpiryTimeOffsetInSeconds Int @default(90) + instantMeetingScheduleId Int? + instantMeetingSchedule Schedule? @relation("InstantMeetingSchedule", fields: [instantMeetingScheduleId], references: [id]) + instantMeetingParameters String[] + assignAllTeamMembers Boolean @default(false) + // It is applicable only when assignAllTeamMembers is true and it filters out all the team members using rrSegmentQueryValue + assignRRMembersUsingSegment Boolean @default(false) + /// @zod.custom(imports.rrSegmentQueryValueSchema) + rrSegmentQueryValue Json? + useEventTypeDestinationCalendarEmail Boolean @default(false) + aiPhoneCallConfig AIPhoneCallConfiguration? + isRRWeightsEnabled Boolean @default(false) + fieldTranslations EventTypeTranslation[] + maxLeadThreshold Int? + includeNoShowInRRCalculation Boolean @default(false) + selectedCalendars SelectedCalendar[] + allowReschedulingPastBookings Boolean @default(false) + hideOrganizerEmail Boolean @default(false) + maxActiveBookingsPerBooker Int? + maxActiveBookingPerBookerOfferReschedule Boolean @default(false) + /// @zod.custom(imports.emailSchema) + customReplyToEmail String? + calVideoSettings CalVideoSettings? + + /// @zod.custom(imports.eventTypeColor) + eventTypeColor Json? + rescheduleWithSameRoundRobinHost Boolean @default(false) + + secondaryEmailId Int? + secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade) + + useBookerTimezone Boolean @default(false) + restrictionScheduleId Int? + restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id]) + + @@unique([userId, slug]) + @@unique([teamId, slug]) + @@unique([userId, parentId]) + @@index([userId]) + @@index([teamId]) + @@index([profileId]) + @@index([scheduleId]) + @@index([secondaryEmailId]) + @@index([parentId]) + @@index([restrictionScheduleId]) +} + +model Credential { + id Int @id @default(autoincrement()) + // @@type is deprecated + type String + key Json + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + // How to make it a required column? + appId String? + + // paid apps + subscriptionId String? + paymentStatus String? + billingCycleStart Int? + + destinationCalendars DestinationCalendar[] + selectedCalendars SelectedCalendar[] + invalid Boolean? @default(false) + CalendarCache CalendarCache[] + references BookingReference[] + delegationCredentialId String? + delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade) + + @@index([appId]) + @@index([subscriptionId]) + @@index([invalid]) + @@index([userId, delegationCredentialId]) +} + +enum IdentityProvider { + CAL + GOOGLE + SAML +} + +model DestinationCalendar { + id Int @id @default(autoincrement()) + integration String + externalId String + /// @zod.custom(imports.emailSchema) + primaryEmail String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? @unique + booking Booking[] + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int? @unique + credentialId Int? + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) + delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade) + delegationCredentialId String? + domainWideDelegation DomainWideDelegation? @relation(fields: [domainWideDelegationCredentialId], references: [id], onDelete: Cascade) + domainWideDelegationCredentialId String? + + @@index([userId]) + @@index([eventTypeId]) + @@index([credentialId]) +} + +enum UserPermissionRole { + USER + ADMIN +} + +// It holds the password of a User, separate from the User model to avoid leaking the password hash +model UserPassword { + hash String + userId Int @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model TravelSchedule { + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + timeZone String + startDate DateTime + endDate DateTime? + prevTimeZone String? + + @@index([startDate]) + @@index([endDate]) +} + +// It holds Personal Profiles of a User plus it has email, password and other core things.. +model User { + id Int @id @default(autoincrement()) + username String? + name String? + /// @zod.custom(imports.emailSchema) + email String + emailVerified DateTime? + password UserPassword? + bio String? + avatarUrl String? + timeZone String @default("Europe/London") + travelSchedules TravelSchedule[] + weekStart String @default("Sunday") + // DEPRECATED - TO BE REMOVED + startTime Int @default(0) + endTime Int @default(1440) + // + bufferTime Int @default(0) + hideBranding Boolean @default(false) + // TODO: should be renamed since it only affects the booking page + theme String? + appTheme String? + createdDate DateTime @default(now()) @map(name: "created") + trialEndsAt DateTime? + lastActiveAt DateTime? + eventTypes EventType[] @relation("user_eventtype") + credentials Credential[] + teams Membership[] + bookings Booking[] + schedules Schedule[] + defaultScheduleId Int? + selectedCalendars SelectedCalendar[] + completedOnboarding Boolean @default(false) + locale String? + timeFormat Int? @default(12) + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + backupCodes String? + identityProvider IdentityProvider @default(CAL) + identityProviderId String? + availability Availability[] + invitedTo Int? + webhooks Webhook[] + brandColor String? + darkBrandColor String? + // the location where the events will end up + destinationCalendar DestinationCalendar? + // participate in dynamic group booking or not + allowDynamicBooking Boolean? @default(true) + + // participate in SEO indexing or not + allowSEOIndexing Boolean? @default(true) + + // receive monthly digest email for teams or not + receiveMonthlyDigestEmail Boolean? @default(true) + + /// @zod.custom(imports.userMetadata) + metadata Json? + verified Boolean? @default(false) + role UserPermissionRole @default(USER) + disableImpersonation Boolean @default(false) + impersonatedUsers Impersonations[] @relation("impersonated_user") + impersonatedBy Impersonations[] @relation("impersonated_by_user") + apiKeys ApiKey[] + accounts Account[] + sessions Session[] + Feedback Feedback[] + ownedEventTypes EventType[] @relation("owner") + workflows Workflow[] + routingForms App_RoutingForms_Form[] @relation("routing-form") + updatedRoutingForms App_RoutingForms_Form[] @relation("updated-routing-form") + verifiedNumbers VerifiedNumber[] + verifiedEmails VerifiedEmail[] + hosts Host[] + // organizationId is deprecated. Instead, rely on the Profile to search profiles by organizationId and then get user from the profile. + organizationId Int? + organization Team? @relation("scope", fields: [organizationId], references: [id], onDelete: SetNull) + accessCodes AccessCode[] + bookingRedirects OutOfOfficeEntry[] + bookingRedirectsTo OutOfOfficeEntry[] @relation(name: "toUser") + + // Used to lock the user account + locked Boolean @default(false) + platformOAuthClients PlatformOAuthClient[] + AccessToken AccessToken[] + RefreshToken RefreshToken[] + PlatformAuthorizationToken PlatformAuthorizationToken[] + profiles Profile[] + movedToProfileId Int? + movedToProfile Profile? @relation("moved_to_profile", fields: [movedToProfileId], references: [id], onDelete: SetNull) + secondaryEmails SecondaryEmail[] + isPlatformManaged Boolean @default(false) + OutOfOfficeReasons OutOfOfficeReason[] + smsLockState SMSLockState @default(UNLOCKED) + smsLockReviewedByAdmin Boolean @default(false) + NotificationsSubscriptions NotificationsSubscriptions[] + referralLinkId String? + features UserFeatures[] + reassignedBookings Booking[] @relation("reassignByUser") + createdAttributeToUsers AttributeToUser[] @relation("createdBy") + updatedAttributeToUsers AttributeToUser[] @relation("updatedBy") + createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations") + updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations") + createdWatchlists Watchlist[] @relation("CreatedWatchlists") + updatedWatchlists Watchlist[] @relation("UpdatedWatchlists") + BookingInternalNote BookingInternalNote[] + creationSource CreationSource? + createdOrganizationOnboardings OrganizationOnboarding[] @relation("CreatedOrganizationOnboardings") + filterSegments FilterSegment[] + filterSegmentPreferences UserFilterSegmentPreference[] + creditBalance CreditBalance? + whitelistWorkflows Boolean @default(false) + + @@unique([email]) + @@unique([email, username]) + @@unique([username, organizationId]) + @@unique([movedToProfileId]) + @@index([username]) + @@index([emailVerified]) + @@index([identityProvider]) + @@index([identityProviderId]) + @@map(name: "users") +} + +model NotificationsSubscriptions { + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + subscription String + + @@index([userId, subscription]) +} + +// It holds Organization Profiles as well as User Profiles for users that have been added to an organization +model Profile { + id Int @id @default(autoincrement()) + // uid allows us to set an identifier chosen by us which is helpful in migration when we create the Profile from User directly. + uid String + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organizationId Int + organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade) + username String + eventTypes EventType[] + movedFromUser User? @relation("moved_to_profile") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // A user can have multiple profiles in different organizations + @@unique([userId, organizationId]) + // Allow username reuse only across different organizations + @@unique([username, organizationId]) + @@index([uid]) + @@index([userId]) + @@index([organizationId]) +} + +model Team { + id Int @id @default(autoincrement()) + /// @zod.min(1) + name String + // It is unique across teams and organizations. We don't have a strong reason for organization and team slug to be conflicting, could be fixed. + // Sub-teams could have same slug across different organizations but not within the same organization. + /// @zod.min(1) + slug String? + logoUrl String? + calVideoLogo String? + appLogo String? + appIconLogo String? + bio String? + hideBranding Boolean @default(false) + hideTeamProfileLink Boolean @default(false) + isPrivate Boolean @default(false) + hideBookATeamMember Boolean @default(false) + members Membership[] + eventTypes EventType[] + workflows Workflow[] + createdAt DateTime @default(now()) + /// @zod.custom(imports.teamMetadataSchema) + metadata Json? + theme String? + rrResetInterval RRResetInterval? @default(MONTH) + rrTimestampBasis RRTimestampBasis @default(CREATED_AT) + brandColor String? + darkBrandColor String? + verifiedNumbers VerifiedNumber[] + verifiedEmails VerifiedEmail[] + bannerUrl String? + parentId Int? + parent Team? @relation("organization", fields: [parentId], references: [id], onDelete: Cascade) + children Team[] @relation("organization") + orgUsers User[] @relation("scope") + inviteTokens VerificationToken[] + webhooks Webhook[] + timeFormat Int? + timeZone String @default("Europe/London") + weekStart String @default("Sunday") + routingForms App_RoutingForms_Form[] + apiKeys ApiKey[] + credentials Credential[] + accessCodes AccessCode[] + isOrganization Boolean @default(false) + organizationSettings OrganizationSettings? + instantMeetingTokens InstantMeetingToken[] + orgProfiles Profile[] + pendingPayment Boolean @default(false) + dsyncTeamGroupMapping DSyncTeamGroupMapping[] + isPlatform Boolean @default(false) + // Organization's OAuth clients. Organization has them but a team does not. + platformOAuthClient PlatformOAuthClient[] + // OAuth client used to create team of an organization. Team has it but organization does not. + createdByOAuthClient PlatformOAuthClient? @relation("CreatedByOAuthClient", fields: [createdByOAuthClientId], references: [id], onDelete: Cascade) + createdByOAuthClientId String? + smsLockState SMSLockState @default(UNLOCKED) + platformBilling PlatformBilling? + activeOrgWorkflows WorkflowsOnTeams[] + attributes Attribute[] + smsLockReviewedByAdmin Boolean @default(false) + // Available for Organization only + delegationCredentials DelegationCredential[] + domainWideDelegations DomainWideDelegation[] + roles Role[] // Added for Role relation + + features TeamFeatures[] + + /// @zod.custom(imports.intervalLimitsType) + bookingLimits Json? + includeManagedEventsInLimits Boolean @default(false) + internalNotePresets InternalNotePreset[] + creditBalance CreditBalance? + organizationOnboarding OrganizationOnboarding? + + // note(Lauris): if a Team has parentId it is a team, if parentId is null it is an organization, but if parentId is null and managedOrganization is set, + // it means that it is an organization managed by another organization. + managedOrganization ManagedOrganization? @relation("ManagedOrganization") + managedOrganizations ManagedOrganization[] @relation("ManagerOrganization") + filterSegments FilterSegment[] + + @@unique([slug, parentId]) + @@index([parentId]) +} + +model CreditBalance { + id String @id @default(uuid()) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? @unique + // user credit balances will be supported in the future + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? @unique + additionalCredits Int @default(0) + limitReachedAt DateTime? + warningSentAt DateTime? + expenseLogs CreditExpenseLog[] + purchaseLogs CreditPurchaseLog[] +} + +model CreditPurchaseLog { + id String @id @default(uuid()) + creditBalanceId String + creditBalance CreditBalance @relation(fields: [creditBalanceId], references: [id], onDelete: Cascade) + credits Int + createdAt DateTime @default(now()) +} + +model CreditExpenseLog { + id String @id @default(uuid()) + creditBalanceId String + creditBalance CreditBalance @relation(fields: [creditBalanceId], references: [id], onDelete: Cascade) + bookingUid String? + booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade) + credits Int? + creditType CreditType + date DateTime + smsSid String? + smsSegments Int? +} + +enum CreditType { + MONTHLY + ADDITIONAL +} + +model OrganizationSettings { + id Int @id @default(autoincrement()) + organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId Int @unique + isOrganizationConfigured Boolean @default(false) + // It decides if new organization members can be auto-accepted or not + isOrganizationVerified Boolean @default(false) + // It is a domain e.g "acme.com". Any email with this domain might be auto-accepted + // Also, it is the domain to which the organization profile is redirected. + orgAutoAcceptEmail String + lockEventTypeCreationForUsers Boolean @default(false) + adminGetsNoSlotsNotification Boolean @default(false) + // It decides if instance ADMIN has reviewed the organization or not. + // It is used to allow super sensitive operations like 'impersonation of Org members by Org admin' + isAdminReviewed Boolean @default(false) + dSyncData DSyncData? + isAdminAPIEnabled Boolean @default(false) + allowSEOIndexing Boolean @default(false) + orgProfileRedirectsToVerifiedDomain Boolean @default(false) + disablePhoneOnlySMSNotifications Boolean @default(false) +} + +enum MembershipRole { + MEMBER + ADMIN + OWNER +} + +model Membership { + id Int @id @default(autoincrement()) + teamId Int + userId Int + accepted Boolean @default(false) + role MembershipRole + customRoleId String? + customRole Role? @relation(fields: [customRoleId], references: [id]) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + disableImpersonation Boolean @default(false) + AttributeToUser AttributeToUser[] + createdAt DateTime? @default(now()) + updatedAt DateTime? @updatedAt + + @@unique([userId, teamId]) + @@index([teamId]) + @@index([userId]) + @@index([accepted]) + @@index([role]) + @@index([customRoleId]) +} + +model VerificationToken { + id Int @id @default(autoincrement()) + identifier String + token String @unique + expires DateTime + expiresInDays Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + teamId Int? + team Team? @relation(fields: [teamId], references: [id]) + secondaryEmailId Int? + secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id]) + + @@unique([identifier, token]) + @@index([token]) + @@index([teamId]) + @@index([secondaryEmailId]) +} + +model InstantMeetingToken { + id Int @id @default(autoincrement()) + token String @unique + expires DateTime + teamId Int + team Team @relation(fields: [teamId], references: [id]) + bookingId Int? @unique + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([token]) +} + +model BookingReference { + id Int @id @default(autoincrement()) + /// @zod.min(1) + type String + /// @zod.min(1) + uid String + meetingId String? + thirdPartyRecurringEventId String? + meetingPassword String? + meetingUrl String? + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + bookingId Int? + externalCalendarId String? + deleted Boolean? + + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull) + credentialId Int? + delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: SetNull) + delegationCredentialId String? + domainWideDelegation DomainWideDelegation? @relation(fields: [domainWideDelegationCredentialId], references: [id], onDelete: SetNull) + domainWideDelegationCredentialId String? + + @@index([bookingId]) + @@index([type]) + @@index([uid]) +} + +model Attendee { + id Int @id @default(autoincrement()) + email String + name String + timeZone String + phoneNumber String? + locale String? @default("en") + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + bookingId Int? + bookingSeat BookingSeat? + noShow Boolean? @default(false) + + @@index([email]) + @@index([bookingId]) +} + +enum BookingStatus { + CANCELLED @map("cancelled") + ACCEPTED @map("accepted") + REJECTED @map("rejected") + PENDING @map("pending") + AWAITING_HOST @map("awaiting_host") +} + +model Booking { + id Int @id @default(autoincrement()) + uid String @unique + // (optional) UID based on slot start/end time & email against duplicates + idempotencyKey String? @unique + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + // User's email at the time of booking + /// @zod.custom(imports.emailSchema) + userPrimaryEmail String? + references BookingReference[] + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + title String + description String? + customInputs Json? + /// @zod.custom(imports.bookingResponses) + responses Json? + startTime DateTime + endTime DateTime + attendees Attendee[] + location String? + createdAt DateTime @default(now()) + updatedAt DateTime? @updatedAt + status BookingStatus @default(ACCEPTED) + paid Boolean @default(false) + payment Payment[] + destinationCalendar DestinationCalendar? @relation(fields: [destinationCalendarId], references: [id]) + destinationCalendarId Int? + cancellationReason String? + rejectionReason String? + reassignReason String? + reassignBy User? @relation("reassignByUser", fields: [reassignById], references: [id]) + reassignById Int? + dynamicEventSlugRef String? + dynamicGroupSlugRef String? + rescheduled Boolean? + fromReschedule String? + recurringEventId String? + smsReminderNumber String? + workflowReminders WorkflowReminder[] + scheduledJobs String[] // scheduledJobs is deprecated, please use scheduledTriggers instead + seatsReferences BookingSeat[] + /// @zod.custom(imports.bookingMetadataSchema) + metadata Json? + isRecorded Boolean @default(false) + iCalUID String? @default("") + iCalSequence Int @default(0) + instantMeetingToken InstantMeetingToken? + rating Int? + ratingFeedback String? + noShowHost Boolean? @default(false) + scheduledTriggers WebhookScheduledTriggers[] + oneTimePassword String? @unique @default(uuid()) + /// @zod.email() + cancelledBy String? + /// @zod.email() + rescheduledBy String? + // Ah, made a typo here. Should have been routedFromRoutingFormRe"s"ponse. Live with it :( + routedFromRoutingFormReponse App_RoutingForms_FormResponse? + assignmentReason AssignmentReason[] + internalNote BookingInternalNote[] + creationSource CreationSource? + tracking Tracking? + routingFormResponses RoutingFormResponseDenormalized[] + expenseLogs CreditExpenseLog[] + + @@index([eventTypeId]) + @@index([userId]) + @@index([destinationCalendarId]) + @@index([recurringEventId]) + @@index([uid]) + @@index([status]) + @@index([startTime, endTime, status]) +} + +model Tracking { + id Int @id @default(autoincrement()) + bookingId Int + booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) + utm_source String? + utm_medium String? + utm_campaign String? + utm_term String? + utm_content String? + + @@unique([bookingId]) +} + +model Schedule { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + eventType EventType[] + instantMeetingEvents EventType[] @relation("InstantMeetingSchedule") + restrictionSchedule EventType[] @relation("restrictionSchedule") + name String + timeZone String? + availability Availability[] + Host Host[] + + @@index([userId]) +} + +model Availability { + id Int @id @default(autoincrement()) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int? + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + eventTypeId Int? + days Int[] + startTime DateTime @db.Time + endTime DateTime @db.Time + date DateTime? @db.Date + Schedule Schedule? @relation(fields: [scheduleId], references: [id]) + scheduleId Int? + + @@index([userId]) + @@index([eventTypeId]) + @@index([scheduleId]) +} + +model SelectedCalendar { + id String @id @default(uuid()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + integration String + externalId String + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) + credentialId Int? + // Used to identify a watched calendar channel in Google Calendar + googleChannelId String? + googleChannelKind String? + googleChannelResourceId String? + googleChannelResourceUri String? + googleChannelExpiration String? + + delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade) + delegationCredentialId String? + + // Deprecated and unused: Use delegationCredential instead + domainWideDelegationCredential DomainWideDelegation? @relation(fields: [domainWideDelegationCredentialId], references: [id], onDelete: Cascade) + domainWideDelegationCredentialId String? + error String? + lastErrorAt DateTime? + watchAttempts Int @default(0) + unwatchAttempts Int @default(0) + maxAttempts Int @default(3) + + eventTypeId Int? + eventType EventType? @relation(fields: [eventTypeId], references: [id]) + + // It could still allow multiple user-level(eventTypeId is null) selected calendars for same userId, integration, externalId because NULL is not equal to NULL + // We currently ensure uniqueness by checking for the existence of the record before creating a new one + // Think about introducing a generated unique key ${userId}_${integration}_${externalId}_${eventTypeId} + @@unique([userId, integration, externalId, eventTypeId]) + @@unique([googleChannelId, eventTypeId]) + @@index([userId]) + @@index([externalId]) + @@index([eventTypeId]) + @@index([credentialId]) + // Composite indices to optimize calendar-cache queries + @@index([integration, googleChannelExpiration, error, watchAttempts, maxAttempts], name: "SelectedCalendar_watch_idx") + @@index([integration, googleChannelExpiration, error, unwatchAttempts, maxAttempts], name: "SelectedCalendar_unwatch_idx") +} + +enum EventTypeCustomInputType { + TEXT @map("text") + TEXTLONG @map("textLong") + NUMBER @map("number") + BOOL @map("bool") + RADIO @map("radio") + PHONE @map("phone") +} + +model EventTypeCustomInput { + id Int @id @default(autoincrement()) + eventTypeId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + label String + type EventTypeCustomInputType + /// @zod.custom(imports.customInputOptionSchema) + options Json? + required Boolean + placeholder String @default("") + + @@index([eventTypeId]) +} + +model ResetPasswordRequest { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String + expires DateTime +} + +enum ReminderType { + PENDING_BOOKING_CONFIRMATION +} + +model ReminderMail { + id Int @id @default(autoincrement()) + referenceId Int + reminderType ReminderType + elapsedMinutes Int + createdAt DateTime @default(now()) + + @@index([referenceId]) + @@index([reminderType]) +} + +model Payment { + id Int @id @default(autoincrement()) + uid String @unique + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? + bookingId Int + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) + amount Int + fee Int + currency String + success Boolean + refunded Boolean + data Json + externalId String @unique + paymentOption PaymentOption? @default(ON_BOOKING) + + @@index([bookingId]) + @@index([externalId]) +} + +enum PaymentOption { + ON_BOOKING + HOLD +} + +enum WebhookTriggerEvents { + BOOKING_CREATED + BOOKING_PAYMENT_INITIATED + BOOKING_PAID + BOOKING_RESCHEDULED + BOOKING_REQUESTED + BOOKING_CANCELLED + BOOKING_REJECTED + BOOKING_NO_SHOW_UPDATED + FORM_SUBMITTED + MEETING_ENDED + MEETING_STARTED + RECORDING_READY + INSTANT_MEETING + RECORDING_TRANSCRIPTION_GENERATED + OOO_CREATED + AFTER_HOSTS_CAL_VIDEO_NO_SHOW + AFTER_GUESTS_CAL_VIDEO_NO_SHOW + FORM_SUBMITTED_NO_EVENT +} + +model Webhook { + id String @id @unique + userId Int? + teamId Int? + eventTypeId Int? + platformOAuthClientId String? + /// @zod.url() + subscriberUrl String + payloadTemplate String? + createdAt DateTime @default(now()) + active Boolean @default(true) + eventTriggers WebhookTriggerEvents[] + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + eventType EventType? @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + platformOAuthClient PlatformOAuthClient? @relation(fields: [platformOAuthClientId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? + secret String? + platform Boolean @default(false) + scheduledTriggers WebhookScheduledTriggers[] + time Int? + timeUnit TimeUnit? + + @@unique([userId, subscriberUrl], name: "courseIdentifier") + @@unique([platformOAuthClientId, subscriberUrl], name: "oauthclientwebhook") + @@index([active]) +} + +model Impersonations { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + impersonatedUser User @relation("impersonated_user", fields: [impersonatedUserId], references: [id], onDelete: Cascade) + impersonatedBy User @relation("impersonated_by_user", fields: [impersonatedById], references: [id], onDelete: Cascade) + impersonatedUserId Int + impersonatedById Int + + @@index([impersonatedUserId]) + @@index([impersonatedById]) +} + +model ApiKey { + id String @id @unique @default(cuid()) + userId Int + teamId Int? + note String? + createdAt DateTime @default(now()) + expiresAt DateTime? + lastUsedAt DateTime? + hashedKey String @unique() + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + appId String? + rateLimits RateLimit[] + + @@index([userId]) +} + +model RateLimit { + id String @id @default(uuid()) + name String + apiKeyId String + ttl Int + limit Int + blockDuration Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade) + + @@index([apiKeyId]) +} + +model HashedLink { + id Int @id @default(autoincrement()) + link String @unique() + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int +} + +model Account { + id String @id @default(cuid()) + userId Int + type String + provider String + providerAccountId String + providerEmail String? + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@index([userId]) + @@index([type]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId Int + expires DateTime + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + +enum AppCategories { + calendar + messaging + other + payment + video // deprecated, please use 'conferencing' instead + web3 // deprecated, we should no longer have any web3 apps + automation + analytics + // Wherever video is in use, conferencing should also be used for legacy apps can have it. + conferencing + crm +} + +model App { + // The slug for the app store public page inside `/apps/[slug]` + slug String @id @unique + // The directory name for `/packages/app-store/[dirName]` + dirName String @unique + // Needed API Keys + keys Json? + // One or multiple categories to which this app belongs + categories AppCategories[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + credentials Credential[] + payments Payment[] + Webhook Webhook[] + ApiKey ApiKey[] + enabled Boolean @default(false) + + @@index([enabled]) +} + +model App_RoutingForms_Form { + id String @id @default(cuid()) + description String? + position Int @default(0) + routes Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + fields Json? + user User @relation("routing-form", fields: [userId], references: [id], onDelete: Cascade) + updatedBy User? @relation("updated-routing-form", fields: [updatedById], references: [id], onDelete: SetNull) + updatedById Int? + // This is the user who created the form and also the user who has read-write access to the form + // If teamId is set, the members of the team would also have access to form readOnly or read-write depending on their permission level as team member. + userId Int + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + responses App_RoutingForms_FormResponse[] + queuedResponses App_RoutingForms_QueuedFormResponse[] + disabled Boolean @default(false) + /// @zod.custom(imports.RoutingFormSettings) + settings Json? + incompleteBookingActions App_RoutingForms_IncompleteBookingActions[] + + @@index([userId]) + @@index([disabled]) +} + +model App_RoutingForms_FormResponse { + id Int @id @default(autoincrement()) + formFillerId String @default(cuid()) + form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId String + response Json + createdAt DateTime @default(now()) + updatedAt DateTime? @updatedAt + + routedToBookingUid String? @unique + // We should not cascade delete the booking, because we want to keep the form response even if the routedToBooking is deleted + routedToBooking Booking? @relation(fields: [routedToBookingUid], references: [uid]) + chosenRouteId String? + routingFormResponseFields RoutingFormResponseField[] + routingFormResponses RoutingFormResponseDenormalized[] + queuedFormResponse App_RoutingForms_QueuedFormResponse? + + @@unique([formFillerId, formId]) + @@index([formFillerId]) + @@index([formId]) + @@index([routedToBookingUid]) +} + +model App_RoutingForms_QueuedFormResponse { + id String @id @default(cuid()) + form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId String + response Json + chosenRouteId String? + createdAt DateTime @default(now()) + updatedAt DateTime? @updatedAt + actualResponseId Int? @unique + actualResponse App_RoutingForms_FormResponse? @relation(fields: [actualResponseId], references: [id], onDelete: Cascade) +} + +model RoutingFormResponseField { + id Int @id @default(autoincrement()) + responseId Int + fieldId String + valueString String? + valueNumber Decimal? + valueStringArray String[] + response App_RoutingForms_FormResponse @relation(fields: [responseId], references: [id], map: "RoutingFormResponseField_response_fkey", onDelete: Cascade) + denormalized RoutingFormResponseDenormalized @relation("DenormalizedResponseToFields", fields: [responseId], references: [id], onDelete: Cascade) + + @@index([responseId]) + @@index([fieldId]) + @@index([valueNumber]) + @@index([valueStringArray], type: Gin) +} + +view RoutingFormResponse { + id Int @unique + response Json + responseLowercase Json + formId String + formName String + formTeamId Int? + formUserId Int? + bookingUid String? + bookingStatus BookingStatus? + bookingStatusOrder Int? + bookingCreatedAt DateTime? + bookingAttendees Json? // Array of {timeZone: string, email: string} + bookingUserId Int? + bookingUserName String? + bookingUserEmail String? + bookingUserAvatarUrl String? + bookingAssignmentReason String? + bookingAssignmentReasonLowercase String? + bookingStartTime DateTime? + bookingEndTime DateTime? + createdAt DateTime + utm_source String? + utm_medium String? + utm_campaign String? + utm_term String? + utm_content String? +} + +model RoutingFormResponseDenormalized { + id Int @id + formId String + formName String + formTeamId Int? + formUserId Int + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull) + bookingUid String? + bookingId Int? + bookingStatus BookingStatus? + bookingStatusOrder Int? + bookingCreatedAt DateTime? @db.Timestamp(3) + bookingStartTime DateTime? @db.Timestamp(3) + bookingEndTime DateTime? @db.Timestamp(3) + bookingUserId Int? + bookingUserName String? + bookingUserEmail String? + bookingUserAvatarUrl String? + bookingAssignmentReason String? + eventTypeId Int? + eventTypeParentId Int? + eventTypeSchedulingType String? + createdAt DateTime @db.Timestamp(3) + utm_source String? + utm_medium String? + utm_campaign String? + utm_term String? + utm_content String? + response App_RoutingForms_FormResponse @relation(fields: [id], references: [id], onDelete: Cascade) + fields RoutingFormResponseField[] @relation("DenormalizedResponseToFields") + + @@index([formId]) + @@index([formTeamId]) + @@index([formUserId]) + @@index([formId, createdAt]) + @@index([bookingId]) + @@index([bookingUserId]) + @@index([eventTypeId, eventTypeParentId]) +} + +model Feedback { + id Int @id @default(autoincrement()) + date DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + rating String + comment String? + + @@index([userId]) + @@index([rating]) +} + +enum WorkflowTriggerEvents { + BEFORE_EVENT + EVENT_CANCELLED + NEW_EVENT + AFTER_EVENT + RESCHEDULE_EVENT + AFTER_HOSTS_CAL_VIDEO_NO_SHOW + AFTER_GUESTS_CAL_VIDEO_NO_SHOW +} + +enum WorkflowActions { + EMAIL_HOST + EMAIL_ATTENDEE + SMS_ATTENDEE + SMS_NUMBER + EMAIL_ADDRESS + WHATSAPP_ATTENDEE + WHATSAPP_NUMBER +} + +model WorkflowStep { + id Int @id @default(autoincrement()) + stepNumber Int + action WorkflowActions + workflowId Int + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + sendTo String? + reminderBody String? + emailSubject String? + template WorkflowTemplates @default(REMINDER) + workflowReminders WorkflowReminder[] + numberRequired Boolean? + sender String? + numberVerificationPending Boolean @default(true) + includeCalendarEvent Boolean @default(false) + verifiedAt DateTime? + + @@index([workflowId]) +} + +model Workflow { + id Int @id @default(autoincrement()) + position Int @default(0) + name String + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + activeOn WorkflowsOnEventTypes[] + activeOnTeams WorkflowsOnTeams[] + isActiveOnAll Boolean @default(false) + trigger WorkflowTriggerEvents + time Int? + timeUnit TimeUnit? + steps WorkflowStep[] + + @@index([userId]) + @@index([teamId]) +} + +model AIPhoneCallConfiguration { + id Int @id @default(autoincrement()) + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int + templateType String @default("CUSTOM_TEMPLATE") + schedulerName String? + generalPrompt String? + yourPhoneNumber String + numberToCall String + guestName String? + guestEmail String? + guestCompany String? + enabled Boolean @default(false) + beginMessage String? + llmId String? + + @@unique([eventTypeId]) + @@index([eventTypeId]) +} + +model WorkflowsOnEventTypes { + id Int @id @default(autoincrement()) + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + workflowId Int + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int + + @@unique([workflowId, eventTypeId]) + @@index([workflowId]) + @@index([eventTypeId]) +} + +model WorkflowsOnTeams { + id Int @id @default(autoincrement()) + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + workflowId Int + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int + + @@unique([workflowId, teamId]) + @@index([workflowId]) + @@index([teamId]) +} + +model Deployment { + /// This is a single row table, so we use a fixed id + id Int @id @default(1) + logo String? + /// @zod.custom(imports.DeploymentTheme) + theme Json? + licenseKey String? + agreedLicenseAt DateTime? +} + +enum TimeUnit { + DAY @map("day") + HOUR @map("hour") + MINUTE @map("minute") +} + +model WorkflowReminder { + id Int @id @default(autoincrement()) + uuid String? @unique @default(uuid()) + bookingUid String? + booking Booking? @relation(fields: [bookingUid], references: [uid]) + method WorkflowMethods + scheduledDate DateTime + referenceId String? @unique + scheduled Boolean + workflowStepId Int? + workflowStep WorkflowStep? @relation(fields: [workflowStepId], references: [id], onDelete: Cascade) + cancelled Boolean? + seatReferenceId String? + isMandatoryReminder Boolean? @default(false) + retryCount Int @default(0) + + @@index([bookingUid]) + @@index([workflowStepId]) + @@index([seatReferenceId]) + @@index([method, scheduled, scheduledDate]) + @@index([cancelled, scheduledDate]) +} + +model WebhookScheduledTriggers { + id Int @id @default(autoincrement()) + jobName String? // jobName is deprecated, not needed when webhook and booking is set + subscriberUrl String + payload String + startAfter DateTime + retryCount Int @default(0) + createdAt DateTime? @default(now()) + appId String? + webhookId String? + webhook Webhook? @relation(fields: [webhookId], references: [id], onDelete: Cascade) + bookingId Int? + booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade) +} + +enum WorkflowTemplates { + REMINDER + CUSTOM + CANCELLED + RESCHEDULED + COMPLETED + RATING +} + +enum WorkflowMethods { + EMAIL + SMS + WHATSAPP +} + +model BookingSeat { + id Int @id @default(autoincrement()) + referenceUid String @unique + bookingId Int + booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) + attendeeId Int @unique + attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade) + /// @zod.custom(imports.bookingSeatDataSchema) + data Json? + metadata Json? + + @@index([bookingId]) + @@index([attendeeId]) +} + +model VerifiedNumber { + id Int @id @default(autoincrement()) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + phoneNumber String + + @@index([userId]) + @@index([teamId]) +} + +model VerifiedEmail { + id Int @id @default(autoincrement()) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + email String + + @@index([userId]) + @@index([teamId]) +} + +model Feature { + // The feature slug, ex: 'v2-workflows' + slug String @id @unique + // If the feature is currently enabled + enabled Boolean @default(false) + // A short description of the feature + description String? + // The type of feature flag + type FeatureType? @default(RELEASE) + // If the flag is considered stale + stale Boolean? @default(false) + lastUsedAt DateTime? + createdAt DateTime? @default(now()) + updatedAt DateTime? @default(now()) @updatedAt + updatedBy Int? + users UserFeatures[] + teams TeamFeatures[] + + @@index([enabled]) + @@index([stale]) +} + +model UserFeatures { + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + feature Feature @relation(fields: [featureId], references: [slug], onDelete: Cascade) + featureId String + assignedAt DateTime @default(now()) + assignedBy String + updatedAt DateTime @updatedAt + + @@id([userId, featureId]) + @@index([userId, featureId]) +} + +model TeamFeatures { + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int + feature Feature @relation(fields: [featureId], references: [slug], onDelete: Cascade) + featureId String + assignedAt DateTime @default(now()) + assignedBy String + updatedAt DateTime @updatedAt + + @@id([teamId, featureId]) + @@index([teamId, featureId]) +} + +enum FeatureType { + RELEASE + EXPERIMENT + OPERATIONAL + KILL_SWITCH + PERMISSION +} + +enum RRResetInterval { + MONTH + DAY +} + +enum RRTimestampBasis { + CREATED_AT + START_TIME +} + +model SelectedSlots { + id Int @id @default(autoincrement()) + eventTypeId Int + userId Int + slotUtcStartDate DateTime + slotUtcEndDate DateTime + uid String + releaseAt DateTime + isSeat Boolean @default(false) + + @@unique(fields: [userId, slotUtcStartDate, slotUtcEndDate, uid], name: "selectedSlotUnique") +} + +model OAuthClient { + clientId String @id @unique + redirectUri String + clientSecret String + name String + logo String? + accessCodes AccessCode[] +} + +model AccessCode { + id Int @id @default(autoincrement()) + code String + clientId String? + client OAuthClient? @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + expiresAt DateTime + scopes AccessScope[] + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int? + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) +} + +enum AccessScope { + READ_BOOKING + READ_PROFILE +} + +view BookingTimeStatus { + id Int @unique + uid String? + eventTypeId Int? + title String? + description String? + startTime DateTime? + endTime DateTime? + createdAt DateTime? + location String? + paid Boolean? + status BookingStatus? + rescheduled Boolean? + userId Int? + teamId Int? + eventLength Int? + timeStatus String? + eventParentId Int? + userEmail String? + username String? + ratingFeedback String? + rating Int? + noShowHost Boolean? + isTeamBooking Boolean +} + +model BookingDenormalized { + id Int @id @unique + uid String + eventTypeId Int? + title String + description String? + startTime DateTime + endTime DateTime + createdAt DateTime + updatedAt DateTime? + location String? + paid Boolean + status BookingStatus + rescheduled Boolean? + userId Int? + teamId Int? + eventLength Int? + eventParentId Int? + userEmail String? + userName String? + userUsername String? + ratingFeedback String? + rating Int? + noShowHost Boolean? + isTeamBooking Boolean + + @@index([userId]) + @@index([createdAt]) + @@index([eventTypeId]) + @@index([eventParentId]) + @@index([teamId]) + @@index([startTime]) + @@index([endTime]) + @@index([status]) + @@index([teamId, isTeamBooking]) + @@index([userId, isTeamBooking]) +} + +view BookingTimeStatusDenormalized { + id Int @id @unique + uid String + eventTypeId Int? + title String + description String? + startTime DateTime + endTime DateTime + createdAt DateTime + updatedAt DateTime? + location String? + paid Boolean + status BookingStatus + rescheduled Boolean? + userId Int? + teamId Int? + eventLength Int? + eventParentId Int? + userEmail String? + userName String? + userUsername String? + ratingFeedback String? + rating Int? + noShowHost Boolean? + isTeamBooking Boolean + timeStatus String? // this is the addition on top of BookingDenormalized +} + +model CalendarCache { + // To be made required in a followup + id String? @default(uuid()) + + // The key would be the unique URL that is requested by the user + key String + value Json + expiresAt DateTime + credentialId Int + userId Int? + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) + + @@id([credentialId, key]) + @@unique([credentialId, key]) + @@index([userId, key]) +} + +enum RedirectType { + UserEventType @map("user-event-type") + TeamEventType @map("team-event-type") + User @map("user") + Team @map("team") +} + +model TempOrgRedirect { + id Int @id @default(autoincrement()) + // Better would be to have fromOrgId and toOrgId as well and then we should have just to instead toUrl + from String + // 0 would mean it is non org + fromOrgId Int + type RedirectType + // It doesn't have any query params + toUrl String + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([from, type, fromOrgId]) +} + +model Avatar { + // e.g. NULL(0), organization ID or team logo + teamId Int @default(0) + // Avatar, NULL(0) if team logo + userId Int @default(0) + // base64 string + data String + // different every time to pop the cache. + objectKey String @unique + + isBanner Boolean @default(false) + + @@unique([teamId, userId, isBanner]) + @@map(name: "avatars") +} + +model OutOfOfficeEntry { + id Int @id @default(autoincrement()) + uuid String @unique + start DateTime + end DateTime + notes String? + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + toUserId Int? + toUser User? @relation(name: "toUser", fields: [toUserId], references: [id], onDelete: Cascade) + reasonId Int? + reason OutOfOfficeReason? @relation(fields: [reasonId], references: [id], onDelete: SetNull) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([uuid]) + @@index([userId]) + @@index([toUserId]) + @@index([start, end]) +} + +model OutOfOfficeReason { + id Int @id @default(autoincrement()) + emoji String + reason String @unique + enabled Boolean @default(true) + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + entries OutOfOfficeEntry[] +} + +// Platform +model PlatformOAuthClient { + id String @id @default(cuid()) + name String + secret String + permissions Int + users User[] + logo String? + redirectUris String[] + organizationId Int + organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade) + teams Team[] @relation("CreatedByOAuthClient") + + accessTokens AccessToken[] + refreshToken RefreshToken[] + authorizationTokens PlatformAuthorizationToken[] + webhook Webhook[] + + bookingRedirectUri String? + bookingCancelRedirectUri String? + bookingRescheduleRedirectUri String? + areEmailsEnabled Boolean @default(false) + areDefaultEventTypesEnabled Boolean @default(true) + areCalendarEventsEnabled Boolean @default(true) + + createdAt DateTime @default(now()) +} + +model PlatformAuthorizationToken { + id String @id @default(cuid()) + + owner User @relation(fields: [userId], references: [id], onDelete: Cascade) + client PlatformOAuthClient @relation(fields: [platformOAuthClientId], references: [id], onDelete: Cascade) + + platformOAuthClientId String + userId Int + + createdAt DateTime @default(now()) + + @@unique([userId, platformOAuthClientId]) +} + +model AccessToken { + id Int @id @default(autoincrement()) + + secret String @unique + createdAt DateTime @default(now()) + expiresAt DateTime + + owner User @relation(fields: [userId], references: [id], onDelete: Cascade) + client PlatformOAuthClient @relation(fields: [platformOAuthClientId], references: [id], onDelete: Cascade) + + platformOAuthClientId String + userId Int +} + +model RefreshToken { + id Int @id @default(autoincrement()) + + secret String @unique + createdAt DateTime @default(now()) + expiresAt DateTime + + owner User @relation(fields: [userId], references: [id], onDelete: Cascade) + client PlatformOAuthClient @relation(fields: [platformOAuthClientId], references: [id], onDelete: Cascade) + + platformOAuthClientId String + userId Int +} + +model DSyncData { + id Int @id @default(autoincrement()) + directoryId String @unique + tenant String + organizationId Int? @unique + org OrganizationSettings? @relation(fields: [organizationId], references: [organizationId], onDelete: Cascade) + teamGroupMapping DSyncTeamGroupMapping[] + + createdAttributeToUsers AttributeToUser[] @relation("createdByDSync") + updatedAttributeToUsers AttributeToUser[] @relation("updatedByDSync") +} + +model DSyncTeamGroupMapping { + id Int @id @default(autoincrement()) + organizationId Int + teamId Int + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + directoryId String + directory DSyncData @relation(fields: [directoryId], references: [directoryId], onDelete: Cascade) + groupName String + + @@unique([teamId, groupName]) +} + +model SecondaryEmail { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + email String + emailVerified DateTime? + verificationTokens VerificationToken[] + eventTypes EventType[] + + @@unique([email]) + @@unique([userId, email]) + @@index([userId]) +} + +// Needed to store tasks that need to be processed by a background worker or Tasker +model Task { + id String @id @unique @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + // The time at which the task should be executed + scheduledAt DateTime @default(now()) + // The time at which the task was successfully executed + succeededAt DateTime? + // The task type to be executed. Left it as a freeform string to avoid more migrations for now. Will be enforced at type level. + type String + // Generic payload for the task + payload String + // The number of times the task has been attempted + attempts Int @default(0) + // The maximum number of times the task can be attempted + maxAttempts Int @default(3) + lastError String? + lastFailedAttemptAt DateTime? + referenceUid String? +} + +enum SMSLockState { + LOCKED + UNLOCKED + REVIEW_NEEDED +} + +model ManagedOrganization { + managedOrganizationId Int @unique + managedOrganization Team @relation("ManagedOrganization", fields: [managedOrganizationId], references: [id], onDelete: Cascade) + + managerOrganizationId Int + managerOrganization Team @relation("ManagerOrganization", fields: [managerOrganizationId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([managerOrganizationId, managedOrganizationId]) + @@index([managerOrganizationId]) +} + +model PlatformBilling { + id Int @id @unique // team id + + customerId String + subscriptionId String? + priceId String? + plan String @default("none") + + billingCycleStart Int? + billingCycleEnd Int? + overdue Boolean? @default(false) + + // note(Lauris): in case of a platform managed organization's billing record this field points to the manager organization's billing record. + managerBillingId Int? + managerBilling PlatformBilling? @relation("PlatformManagedBilling", fields: [managerBillingId], references: [id]) + // note(Lauris): in case of a manager organization's billing record this field points to billing records of its platform managed organizations. + managedBillings PlatformBilling[] @relation("PlatformManagedBilling") + + team Team @relation(fields: [id], references: [id], onDelete: Cascade) +} + +enum AttributeType { + TEXT + NUMBER + SINGLE_SELECT + MULTI_SELECT +} + +model AttributeOption { + id String @id @default(uuid()) + attribute Attribute @relation(fields: [attributeId], references: [id], onDelete: Cascade) + attributeId String + value String + slug String + isGroup Boolean @default(false) + // It is a list of AttributeOptions ids that are contained in the group option + // You could think of a person having the group option to actually have all the options in the contains list. + // We are not using relation here because it would be a many to many relation because a group option can contain many non-group options and a non-group option can be contained in many group options + // Such a relation would require its own table to be managed and we don't need it for now. + contains String[] + assignedUsers AttributeToUser[] +} + +model Attribute { + id String @id @default(uuid()) + + // This is organization + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + // This is organizationId + teamId Int + + type AttributeType + + name String + slug String @unique + + enabled Boolean @default(true) + + usersCanEditRelation Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + options AttributeOption[] + isWeightsEnabled Boolean @default(false) + isLocked Boolean @default(false) + + @@index([teamId]) +} + +model AttributeToUser { + id String @id @default(uuid()) + + // This is the membership of the organization + member Membership @relation(fields: [memberId], references: [id], onDelete: Cascade) + + // This is the membership id of the organization + memberId Int + + attributeOption AttributeOption @relation(fields: [attributeOptionId], references: [id], onDelete: Cascade) + attributeOptionId String + + weight Int? + + // We don't intentionally delete assignments on deletion of a user/directory sync + createdAt DateTime @default(now()) + createdById Int? + createdBy User? @relation("createdBy", fields: [createdById], references: [id], onDelete: SetNull) + createdByDSyncId String? + createdByDSync DSyncData? @relation("createdByDSync", fields: [createdByDSyncId], references: [directoryId], onDelete: SetNull) + + updatedAt DateTime? @updatedAt + updatedBy User? @relation("updatedBy", fields: [updatedById], references: [id], onDelete: SetNull) + updatedById Int? + updatedByDSyncId String? + updatedByDSync DSyncData? @relation("updatedByDSync", fields: [updatedByDSyncId], references: [directoryId], onDelete: SetNull) + + @@unique([memberId, attributeOptionId]) +} + +enum AssignmentReasonEnum { + ROUTING_FORM_ROUTING + ROUTING_FORM_ROUTING_FALLBACK + REASSIGNED + RR_REASSIGNED + REROUTED + SALESFORCE_ASSIGNMENT +} + +model AssignmentReason { + id Int @id @unique @default(autoincrement()) + createdAt DateTime @default(now()) + bookingId Int + booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) + reasonEnum AssignmentReasonEnum + reasonString String + + @@index([bookingId]) +} + +enum EventTypeAutoTranslatedField { + DESCRIPTION + TITLE +} + +model DelegationCredential { + id String @id @default(uuid()) + workspacePlatform WorkspacePlatform @relation(fields: [workspacePlatformId], references: [id], onDelete: Cascade) + workspacePlatformId Int + // Provides possibility to have different service accounts for different organizations if the need arises, but normally they should be the same + /// @zod.custom(imports.serviceAccountKeySchema) + serviceAccountKey Json + enabled Boolean @default(false) + // lastEnabledAt is set when the delegation credential is enabled + lastEnabledAt DateTime? + // lastDisabledAt is set when the delegation credential is disabled. So, lastDisabledAt could be earlier then lastEnabledAt if the delegation credential was enabled -> then disabled -> then enabled again. + lastDisabledAt DateTime? + organizationId Int + organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade) + domain String + selectedCalendars SelectedCalendar[] + destinationCalendar DestinationCalendar[] + bookingReferences BookingReference[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + credentials Credential[] + + // Should be fair to assume that one domain can be only on one workspace platform at a time. So, one can't have two different workspace platforms for the same domain + // Because we don't know which domain the organization might have, we couldn't make "domain" unique here as that would prevent an actual owner of the domain to be unable to use that domain if it is used by someone else. + @@unique([organizationId, domain]) + @@index([enabled]) +} + +// Deprecated and probably unused - Use DelegationCredential instead +model DomainWideDelegation { + id String @id @default(uuid()) + workspacePlatform WorkspacePlatform @relation(fields: [workspacePlatformId], references: [id], onDelete: Cascade) + workspacePlatformId Int + // Provides possibility to have different service accounts for different organizations if the need arises, but normally they should be the same + /// @zod.custom(imports.serviceAccountKeySchema) + serviceAccountKey Json + enabled Boolean @default(false) + organizationId Int + organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade) + domain String + selectedCalendars SelectedCalendar[] + destinationCalendar DestinationCalendar[] + bookingReferences BookingReference[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Should be fair to assume that one domain can be only on one workspace platform at a time. So, one can't have two different workspace platforms for the same domain + // Because we don't know which domain the organization might have, we couldn't make "domain" unique here as that would prevent an actual owner of the domain to be unable to use that domain if it is used by someone else. + @@unique([organizationId, domain]) +} + +// It is for delegation credential +model WorkspacePlatform { + id Int @id @default(autoincrement()) + /// @zod.min(1) + slug String + /// @zod.min(1) + name String + description String + /// @zod.custom(imports.serviceAccountKeySchema) + defaultServiceAccountKey Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + enabled Boolean @default(false) + delegationCredentials DelegationCredential[] + domainWideDelegations DomainWideDelegation[] + + @@unique([slug]) +} + +model EventTypeTranslation { + uid String @id @default(cuid()) + eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) + eventTypeId Int + field EventTypeAutoTranslatedField + sourceLocale String + targetLocale String + translatedText String @db.Text + createdAt DateTime @default(now()) + createdBy Int + updatedAt DateTime @updatedAt + updatedBy Int? + creator User @relation("CreatedEventTypeTranslations", fields: [createdBy], references: [id]) + updater User? @relation("UpdatedEventTypeTranslations", fields: [updatedBy], references: [id], onDelete: SetNull) + + @@unique([eventTypeId, field, targetLocale]) + @@index([eventTypeId, field, targetLocale]) +} + +enum WatchlistType { + EMAIL + DOMAIN + USERNAME +} + +enum WatchlistSeverity { + LOW + MEDIUM + HIGH + CRITICAL +} + +model Watchlist { + id String @id @unique @default(cuid()) + type WatchlistType + // The identifier of the Watchlisted entity (email or domain) + value String + description String? + createdAt DateTime @default(now()) + createdBy User @relation("CreatedWatchlists", onDelete: Cascade, fields: [createdById], references: [id]) + createdById Int + updatedAt DateTime @updatedAt + updatedBy User? @relation("UpdatedWatchlists", onDelete: SetNull, fields: [updatedById], references: [id]) + updatedById Int? + severity WatchlistSeverity @default(LOW) + + @@unique([type, value]) + @@index([type, value]) +} + +enum BillingPeriod { + MONTHLY + ANNUALLY +} + +model OrganizationOnboarding { + // TODO: Use uuid for id + id String @id @default(uuid()) + + // User who started the onboarding. It is different from orgOwnerEmail in case Cal.com admin is doing the onboarding for someone else. + createdBy User @relation("CreatedOrganizationOnboardings", fields: [createdById], references: [id], onDelete: Cascade) + createdById Int + createdAt DateTime @default(now()) + + // We keep the email only here and don't need to connect it with user because on User deletion, we don't delete the entry here. + // It is unique because an email can be the owner of only one organization at a time. + orgOwnerEmail String @unique + error String? + + updatedAt DateTime @updatedAt + // TODO: updatedBy to be added when we support marking updatedBy using webhook too, as webhook also updates it + + // Set after organization payment is done and the organization is created + organizationId Int? @unique + organization Team? @relation(fields: [organizationId], references: [id], onDelete: Cascade) + + billingPeriod BillingPeriod + pricePerSeat Float + seats Int + + isPlatform Boolean @default(false) + + // Organization info + name String + // We don't keep it unique because we don't want self-serve flows to block a slug if it isn't paid for yet. + slug String + logo String? + bio String? + isDomainConfigured Boolean @default(false) + + // Set when payment intent is there. + stripeCustomerId String? @unique + // TODO: Can we make it required + stripeSubscriptionId String? + stripeSubscriptionItemId String? + + /// @zod.custom(imports.orgOnboardingInvitedMembersSchema) + invitedMembers Json @default("[]") + + /// @zod.custom(imports.orgOnboardingTeamsSchema) + teams Json @default("[]") + + // Completion status + isComplete Boolean @default(false) + + @@index([orgOwnerEmail]) + @@index([stripeCustomerId]) +} + +enum IncompleteBookingActionType { + SALESFORCE +} + +model App_RoutingForms_IncompleteBookingActions { + id Int @id @default(autoincrement()) + form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade) + formId String + actionType IncompleteBookingActionType + data Json + enabled Boolean @default(true) + credentialId Int? +} + +model InternalNotePreset { + id Int @id @default(autoincrement()) + name String + cancellationReason String? + team Team @relation(fields: [teamId], references: [id]) + teamId Int + + createdAt DateTime @default(now()) + BookingInternalNote BookingInternalNote[] + + @@unique([teamId, name]) + @@index([teamId]) +} + +enum FilterSegmentScope { + USER + TEAM +} + +model FilterSegment { + id Int @id @default(autoincrement()) + name String + // Identifies which data table this segment belongs to (e.g. "organization_members", "team_members", "bookings", etc.) + tableIdentifier String + scope FilterSegmentScope + // Filter configuration + activeFilters Json? + sorting Json? + columnVisibility Json? + columnSizing Json? + perPage Int + searchTerm String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + // Creator of the segment + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + // Team scope - optional, only set when scope is TEAM + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId Int? + userPreferences UserFilterSegmentPreference[] + + // For user-scoped segments: scope + userId + tableIdentifier + @@index([scope, userId, tableIdentifier]) + // For team-scoped segments: scope + teamId + tableIdentifier + @@index([scope, teamId, tableIdentifier]) +} + +model UserFilterSegmentPreference { + id Int @id @default(autoincrement()) + userId Int + tableIdentifier String + segmentId Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + segment FilterSegment @relation(fields: [segmentId], references: [id], onDelete: Cascade) + + @@unique([userId, tableIdentifier]) + @@index([userId]) + @@index([segmentId]) +} + +model BookingInternalNote { + id Int @id @default(autoincrement()) + + notePreset InternalNotePreset? @relation(fields: [notePresetId], references: [id], onDelete: Cascade) + notePresetId Int? + text String? + + booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) + bookingId Int + + createdBy User @relation(fields: [createdById], references: [id]) + createdById Int + + createdAt DateTime @default(now()) + + @@unique([bookingId, notePresetId]) + @@index([bookingId]) +} + +enum WorkflowContactType { + PHONE + EMAIL +} + +model WorkflowOptOutContact { + id Int @id @default(autoincrement()) + type WorkflowContactType + value String + optedOut Boolean + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([type, value]) +} + +enum RoleType { + SYSTEM + CUSTOM +} + +model Role { + id String @id @default(cuid()) + name String + description String? + teamId Int? // null for global roles + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + permissions RolePermission[] + memberships Membership[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + type RoleType @default(CUSTOM) + + @@unique([name, teamId]) + @@index([teamId]) +} + +model RolePermission { + id String @id @default(cuid()) + roleId String + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + resource String + action String + createdAt DateTime @default(now()) + + @@unique([roleId, resource, action]) + @@index([roleId]) + // TODO: come back to this with indexs. + @@index([action]) +} diff --git a/tests/e2e/formbricks/formbricks.test.ts b/tests/e2e/formbricks/formbricks.test.ts new file mode 100644 index 00000000..1e16f6dd --- /dev/null +++ b/tests/e2e/formbricks/formbricks.test.ts @@ -0,0 +1,12 @@ +import { generateTsSchema } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +describe('Formbricks e2e tests', () => { + it('has a working schema', async () => { + await expect( + generateTsSchema(fs.readFileSync(path.join(__dirname, 'schema.zmodel'), 'utf8'), 'postgresql', 'cal-com'), + ).resolves.toBeTruthy(); + }); +}); diff --git a/tests/e2e/formbricks/schema.zmodel b/tests/e2e/formbricks/schema.zmodel new file mode 100644 index 00000000..7a7c19f3 --- /dev/null +++ b/tests/e2e/formbricks/schema.zmodel @@ -0,0 +1,1101 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + extensions = [pgvector(map: "vector")] +} + +generator client { + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions"] +} + +generator json { + provider = "prisma-json-types-generator" +} + +enum PipelineTriggers { + responseCreated + responseUpdated + responseFinished +} + +enum WebhookSource { + user + zapier + make + n8n + activepieces +} + +/// Represents a webhook endpoint for receiving survey-related events. +/// Webhooks can be configured to receive notifications about response creation, updates, and completion. +/// +/// @property id - Unique identifier for the webhook +/// @property name - Optional display name for the webhook +/// @property url - The endpoint URL where events will be sent +/// @property source - Origin of the webhook (user, zapier, make, etc.) +/// @property environment - Associated environment +/// @property triggers - Types of events that trigger this webhook +/// @property surveyIds - List of surveys this webhook is monitoring +model Webhook { + id String @id @default(cuid()) + name String? + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") + url String + source WebhookSource @default(user) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + environmentId String + triggers PipelineTriggers[] + surveyIds String[] + + @@index([environmentId]) +} + +/// Represents an attribute value associated with a contact. +/// Used to store custom properties and characteristics of contacts. +/// +/// @property id - Unique identifier for the attribute +/// @property attributeKey - Reference to the attribute definition +/// @property contact - The contact this attribute belongs to +/// @property value - The actual value of the attribute +model ContactAttribute { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + attributeKey ContactAttributeKey @relation(fields: [attributeKeyId], references: [id], onDelete: Cascade) + attributeKeyId String + contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) + contactId String + value String + + @@unique([contactId, attributeKeyId]) + @@index([attributeKeyId, value]) +} + +enum ContactAttributeType { + default + custom +} + +/// Defines the possible attributes that can be assigned to contacts. +/// Acts as a schema for contact attributes within an environment. +/// +/// @property id - Unique identifier for the attribute key +/// @property isUnique - Whether the attribute must have unique values across contacts +/// @property key - The attribute identifier used in the system +/// @property name - Display name for the attribute +/// @property type - Whether this is a default or custom attribute +/// @property environment - The environment this attribute belongs to +model ContactAttributeKey { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + isUnique Boolean @default(false) + key String + name String? + description String? + type ContactAttributeType @default(custom) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + environmentId String + attributes ContactAttribute[] + attributeFilters SurveyAttributeFilter[] + + @@unique([key, environmentId]) + @@index([environmentId, createdAt]) +} + +/// Represents a person or user who can receive and respond to surveys. +/// Contacts are environment-specific and can have multiple attributes and responses. +/// +/// @property id - Unique identifier for the contact +/// @property userId - Optional external user identifier +/// @property environment - The environment this contact belongs to +/// @property responses - Survey responses from this contact +/// @property attributes - Custom attributes associated with this contact +/// @property displays - Record of surveys shown to this contact +model Contact { + id String @id @default(cuid()) + userId String? + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + environmentId String + responses Response[] + attributes ContactAttribute[] + displays Display[] + + @@index([environmentId]) +} + +/// Stores a user's response to a survey, including their answers and metadata. +/// Each response is linked to a specific survey and optionally to a contact. +/// +/// @property id - Unique identifier for the response +/// @property finished - Whether the survey was completed +/// @property survey - The associated survey +/// @property contact - The optional contact who provided the response +/// @property data - JSON object containing the actual response data +/// @property variables - Custom variables used in the response +/// @property ttc - Time to completion metrics +/// @property meta - Additional metadata about the response +model Response { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") + finished Boolean @default(false) + survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade) + surveyId String + contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade) + contactId String? + endingId String? + notes ResponseNote[] + /// [ResponseData] + data Json @default("{}") + /// [ResponseVariables] + variables Json @default("{}") + /// [ResponseTtc] + ttc Json @default("{}") + /// [ResponseMeta] + meta Json @default("{}") + tags TagsOnResponses[] + /// [ResponseContactAttributes] + contactAttributes Json? + // singleUseId, used to prevent multiple responses + singleUseId String? + language String? + documents Document[] + displayId String? @unique + display Display? @relation(fields: [displayId], references: [id]) + + @@unique([surveyId, singleUseId]) + @@index([createdAt]) + @@index([surveyId, createdAt]) // to determine monthly response count + @@index([contactId, createdAt]) // to determine monthly identified users (persons) + @@index([surveyId]) +} + +/// Represents notes or comments added to survey responses by team members. +/// Used for internal communication and response analysis. +/// +/// @property id - Unique identifier for the note +/// @property response - The associated survey response +/// @property user - The team member who created the note +/// @property text - Content of the note +/// @property isResolved - Whether the note has been marked as resolved +/// @property isEdited - Whether the note has been modified +model ResponseNote { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + response Response @relation(fields: [responseId], references: [id], onDelete: Cascade) + responseId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + text String + isResolved Boolean @default(false) + isEdited Boolean @default(false) + + @@index([responseId]) +} + +/// Represents a label that can be applied to survey responses. +/// Used for categorizing and organizing responses within an environment. +/// +/// @property id - Unique identifier for the tag +/// @property name - Display name of the tag +/// @property responses - Survey responses tagged with this label +/// @property environment - The environment this tag belongs to +model Tag { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + responses TagsOnResponses[] + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + + @@unique([environmentId, name]) + @@index([environmentId]) +} + +/// Junction table linking tags to responses. +/// Enables many-to-many relationship between tags and responses. +/// +/// @property response - The tagged response +/// @property tag - The tag applied to the response +model TagsOnResponses { + responseId String + response Response @relation(fields: [responseId], references: [id], onDelete: Cascade) + tagId String + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@id([responseId, tagId]) + @@index([responseId]) +} + +enum SurveyStatus { + draft + scheduled + inProgress + paused + completed +} + +enum DisplayStatus { + seen + responded +} + +/// Records when a survey is shown to a user. +/// Tracks survey display history and response status. +/// +/// @property id - Unique identifier for the display event +/// @property survey - The survey that was displayed +/// @property contact - The contact who saw the survey +/// @property status - Whether the survey was just seen or responded to +/// @property response - The associated response if one exists +model Display { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade) + surveyId String + contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade) + contactId String? + responseId String? @unique //deprecated + status DisplayStatus? + response Response? + + @@index([surveyId]) + @@index([contactId, createdAt]) +} + +/// Links surveys to specific trigger actions. +/// Defines which user actions should cause a survey to be displayed. +/// This is the connection table between Surveys and ActionClasses that determines +/// when and under what conditions a survey should be triggered. +/// +/// @property id - Unique identifier for the trigger +/// @property survey - The survey to be triggered +/// @property actionClass - The action that triggers the survey +/// @property createdAt - When the trigger was created +/// @property updatedAt - When the trigger was last modified +model SurveyTrigger { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade) + surveyId String + actionClass ActionClass @relation(fields: [actionClassId], references: [id], onDelete: Cascade) + actionClassId String + + @@unique([surveyId, actionClassId]) + @@index([surveyId]) +} + +enum SurveyAttributeFilterCondition { + equals + notEquals +} + +/// Defines targeting rules for surveys based on contact attributes. +/// Used to show surveys only to contacts matching specific criteria. +/// +/// @property id - Unique identifier for the filter +/// @property attributeKey - The contact attribute to filter on +/// @property survey - The survey being filtered +/// @property condition - The comparison operator to use +/// @property value - The value to compare against +model SurveyAttributeFilter { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + attributeKey ContactAttributeKey @relation(fields: [attributeKeyId], references: [id], onDelete: Cascade) + attributeKeyId String + survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade) + surveyId String + condition SurveyAttributeFilterCondition + value String + + @@unique([surveyId, attributeKeyId]) + @@index([surveyId]) + @@index([attributeKeyId]) +} + +enum SurveyType { + link + web + website + app +} + +enum displayOptions { + displayOnce + displayMultiple + displaySome + respondMultiple +} + +/// Represents a complete survey configuration including questions, styling, and display rules. +/// Core model for the survey functionality in Formbricks. +/// +/// @property id - Unique identifier for the survey +/// @property name - Display name of the survey +/// @property type - Survey delivery method (link, web, website, app) +/// @property status - Current state of the survey (draft, active, completed, etc) +/// @property environment - The environment this survey belongs to +/// @property questions - JSON array containing survey questions configuration +/// @property displayOption - Rules for how often the survey can be shown +/// @property triggers - Actions that can trigger this survey +/// @property attributeFilters - Rules for targeting specific contacts +model Survey { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + redirectUrl String? + type SurveyType @default(web) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + environmentId String + creator User? @relation(fields: [createdBy], references: [id]) + createdBy String? + status SurveyStatus @default(draft) + /// [SurveyWelcomeCard] + welcomeCard Json @default("{\"enabled\": false}") + /// [SurveyQuestions] + questions Json @default("[]") + /// [SurveyEnding] + endings Json[] @default([]) + thankYouCard Json? //deprecated + /// [SurveyHiddenFields] + hiddenFields Json @default("{\"enabled\": false}") + /// [SurveyVariables] + variables Json @default("[]") + responses Response[] + displayOption displayOptions @default(displayOnce) + recontactDays Int? + displayLimit Int? + triggers SurveyTrigger[] + /// [SurveyInlineTriggers] + inlineTriggers Json? + attributeFilters SurveyAttributeFilter[] + displays Display[] + autoClose Int? + autoComplete Int? + delay Int @default(0) + runOnDate DateTime? + closeOnDate DateTime? + /// [SurveyClosedMessage] + surveyClosedMessage Json? + segmentId String? + segment Segment? @relation(fields: [segmentId], references: [id]) + + /// [SurveyProjectOverwrites] + projectOverwrites Json? + + /// [SurveyStyling] + styling Json? + + /// [SurveySingleUse] + singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}") + + /// [SurveyVerifyEmail] + verifyEmail Json? // deprecated + isVerifyEmailEnabled Boolean @default(false) + isSingleResponsePerEmailEnabled Boolean @default(false) + isBackButtonHidden Boolean @default(false) + pin String? + resultShareKey String? @unique + displayPercentage Decimal? + languages SurveyLanguage[] + showLanguageSwitch Boolean? + documents Document[] + followUps SurveyFollowUp[] + /// [SurveyRecaptcha] + recaptcha Json? @default("{\"enabled\": false, \"threshold\":0.1}") + + @@index([environmentId, updatedAt]) + @@index([segmentId]) +} + +/// Defines follow-up actions for survey responses. +/// Enables automated actions based on specific survey response conditions. +/// +/// @property id - Unique identifier for the follow-up +/// @property survey - The associated survey +/// @property name - Display name for the follow-up +/// @property trigger - Conditions that activate the follow-up +/// @property action - Actions to take when triggered +model SurveyFollowUp { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade) + surveyId String + name String + /// [SurveyFollowUpTrigger] + trigger Json + /// [SurveyFollowUpAction] + action Json +} + +enum ActionType { + code + noCode +} + +/// Represents a user action that can trigger surveys. +/// Used to define points in the user journey where surveys can be shown. +/// +/// @property id - Unique identifier for the action +/// @property name - Display name of the action +/// @property type - Whether this is a code or no-code action +/// @property key - Unique identifier used in code implementation +/// @property noCodeConfig - Configuration for no-code setup +/// @property environment - The environment this action belongs to +model ActionClass { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + description String? + type ActionType + key String? + /// [ActionClassNoCodeConfig] + noCodeConfig Json? + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + environmentId String + surveyTriggers SurveyTrigger[] + + @@unique([key, environmentId]) + @@unique([name, environmentId]) + @@index([environmentId, createdAt]) +} + +enum EnvironmentType { + production + development +} + +enum IntegrationType { + googleSheets + notion + airtable + slack +} + +/// Represents third-party service integrations. +/// Enables data flow between Formbricks and external services. +/// +/// @property id - Unique identifier for the integration +/// @property type - The service being integrated (Google Sheets, Notion, etc.) +/// @property environment - The environment this integration belongs to +/// @property config - Service-specific configuration details +model Integration { + id String @id @default(cuid()) + type IntegrationType + environmentId String + /// [IntegrationConfig] + config Json + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + + @@unique([type, environmentId]) + @@index([environmentId]) +} + +enum DataMigrationStatus { + pending + applied + failed +} + +/// Tracks database schema migrations. +/// Used to manage and track the state of data structure changes. +/// +/// @property id - Unique identifier for the migration +/// @property name - Name of the migration +/// @property status - Current state of the migration (pending, applied, failed) +/// @property startedAt - When the migration began +/// @property finishedAt - When the migration completed +model DataMigration { + id String @id @default(cuid()) + startedAt DateTime @default(now()) @map(name: "started_at") + finishedAt DateTime? @map(name: "finished_at") + name String @unique + status DataMigrationStatus +} + +/// Represents either a production or development environment within a project. +/// Each project has exactly two environments, serving as the main reference point +/// for most Formbricks resources including surveys and actions. +/// +/// @property id - Unique identifier for the environment +/// @property type - Either 'production' or 'development' +/// @property project - Reference to parent project +/// @property widgetSetupCompleted - Tracks initial widget setup status +/// @property surveys - Collection of surveys in this environment +/// @property contacts - Collection of contacts/users tracked +/// @property actionClasses - Defined actions that can trigger surveys +/// @property attributeKeys - Custom attributes configuration +model Environment { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + type EnvironmentType + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + projectId String + widgetSetupCompleted Boolean @default(false) + appSetupCompleted Boolean @default(false) + surveys Survey[] + contacts Contact[] + actionClasses ActionClass[] + attributeKeys ContactAttributeKey[] + webhooks Webhook[] + tags Tag[] + segments Segment[] + integration Integration[] + documents Document[] + insights Insight[] + ApiKeyEnvironment ApiKeyEnvironment[] + + @@index([projectId]) +} + +enum WidgetPlacement { + bottomLeft + bottomRight + topLeft + topRight + center +} + +/// Main grouping mechanism for resources in Formbricks. +/// Each organization can have multiple projects to separate different applications or products. +/// +/// @property id - Unique identifier for the project +/// @property name - Display name of the project +/// @property organization - Reference to parent organization +/// @property environments - Development and production environments +/// @property styling - Project-wide styling configuration +/// @property config - Project-specific configuration +/// @property recontactDays - Default recontact delay for surveys +/// @property placement - Default widget placement for in-app surveys +model Project { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String + environments Environment[] + brandColor String? // deprecated; use styling.brandColor instead + highlightBorderColor String? // deprecated + /// [Styling] + styling Json @default("{\"allowStyleOverwrite\":true}") + /// [ProjectConfig] + config Json @default("{}") + recontactDays Int @default(7) + linkSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in link surveys + inAppSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in in-app surveys + placement WidgetPlacement @default(bottomRight) + clickOutsideClose Boolean @default(true) + darkOverlay Boolean @default(false) + languages Language[] + /// [Logo] + logo Json? + projectTeams ProjectTeam[] + + @@unique([organizationId, name]) + @@index([organizationId]) +} + +/// Represents the top-level organizational hierarchy in Formbricks. +/// Self-hosting instances typically have a single organization, while Formbricks Cloud +/// supports multiple organizations with multi-tenancy. +/// +/// @property id - Unique identifier for the organization +/// @property name - Display name of the organization +/// @property memberships - User memberships within the organization +/// @property projects - Collection of projects owned by the organization +/// @property billing - JSON field containing billing information +/// @property whitelabel - Whitelabel configuration for the organization +/// @property isAIEnabled - Controls access to AI-powered features +model Organization { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + memberships Membership[] + projects Project[] + /// [OrganizationBilling] + billing Json + /// [OrganizationWhitelabel] + whitelabel Json @default("{}") + invites Invite[] + isAIEnabled Boolean @default(false) + teams Team[] + apiKeys ApiKey[] +} + +enum OrganizationRole { + owner + manager + member + billing +} + +enum MembershipRole { + owner + admin + editor + developer + viewer +} + +/// Links users to organizations with specific roles. +/// Manages organization membership and permissions. +/// Core model for managing user access within organizations. +/// +/// @property organization - The organization the user belongs to +/// @property user - The member user +/// @property accepted - Whether the user has accepted the membership +/// @property role - User's role within the organization (owner, manager, member, billing) +model Membership { + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + accepted Boolean @default(false) + deprecatedRole MembershipRole? //deprecated + role OrganizationRole @default(member) + + @@id([userId, organizationId]) + @@index([userId]) + @@index([organizationId]) +} + +/// Represents pending invitations to join an organization. +/// Used to manage the process of adding new users to an organization. +/// Once accepted, invites are converted into memberships. +/// +/// @property id - Unique identifier for the invite +/// @property email - Email address of the invited user +/// @property name - Optional display name for the invited user +/// @property organization - The organization sending the invite +/// @property creator - The user who created the invite +/// @property acceptor - The user who accepted the invite (if accepted) +/// @property expiresAt - When the invite becomes invalid +/// @property role - Intended role for the invited user +/// @property teamIds - Teams the user will be added to upon acceptance +model Invite { + id String @id @default(uuid()) + email String + name String? + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String + creator User @relation("inviteCreatedBy", fields: [creatorId], references: [id]) + creatorId String + acceptor User? @relation("inviteAcceptedBy", fields: [acceptorId], references: [id], onDelete: Cascade) + acceptorId String? + createdAt DateTime @default(now()) + expiresAt DateTime + deprecatedRole MembershipRole? //deprecated + role OrganizationRole @default(member) + teamIds String[] @default([]) + + @@index([email, organizationId]) + @@index([organizationId]) +} + +/// Represents enhanced API authentication keys with organization-level ownership. +/// Used for authenticating API requests to Formbricks with more granular permissions. +/// +/// @property id - Unique identifier for the API key +/// @property label - Optional descriptive name for the key +/// @property hashedKey - Securely stored API key +/// @property organization - The organization this key belongs to +/// @property createdBy - User ID who created this key +/// @property lastUsedAt - Timestamp of last usage +/// @property apiKeyEnvironments - Environments this key has access to +model ApiKey { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + createdBy String? + lastUsedAt DateTime? + label String + hashedKey String @unique + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + apiKeyEnvironments ApiKeyEnvironment[] + /// [OrganizationAccess] + organizationAccess Json @default("{}") + + @@index([organizationId]) +} + +/// Defines permission levels for API keys. +/// Controls what operations an API key can perform. +enum ApiKeyPermission { + read + write + manage +} + +/// Links API keys to environments with specific permissions. +/// Enables granular access control for API keys across environments. +/// +/// @property id - Unique identifier for the environment access entry +/// @property apiKey - The associated API key +/// @property environment - The environment being accessed +/// @property permission - Level of access granted +model ApiKeyEnvironment { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + apiKeyId String + apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade) + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + permission ApiKeyPermission + + @@unique([apiKeyId, environmentId]) + @@index([environmentId]) +} + +enum IdentityProvider { + email + github + google + azuread + openid + saml +} + +/// Stores third-party authentication account information. +/// Enables OAuth and other external authentication methods. +/// +/// @property id - Unique identifier for the account +/// @property user - The Formbricks user who owns this account +/// @property provider - The authentication provider (GitHub, Google, etc.) +/// @property providerAccountId - User ID from the provider +/// @property access_token - OAuth access token +/// @property refresh_token - OAuth refresh token +model Account { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + type String + provider String + providerAccountId String + access_token String? @db.Text + refresh_token String? @db.Text + expires_at Int? + ext_expires_in Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + + @@unique([provider, providerAccountId]) + @@index([userId]) +} + +enum Role { + project_manager + engineer + founder + marketing_specialist + other +} + +enum Objective { + increase_conversion + improve_user_retention + increase_user_adoption + sharpen_marketing_messaging + support_sales + other +} + +enum Intention { + survey_user_segments + survey_at_specific_point_in_user_journey + enrich_customer_profiles + collect_all_user_feedback_on_one_platform + other +} + +/// Represents a user in the Formbricks system. +/// Central model for user authentication and profile management. +/// +/// @property id - Unique identifier for the user +/// @property name - Display name of the user +/// @property email - User's email address +/// @property role - User's professional role +/// @property objective - User's main goal with Formbricks +/// @property twoFactorEnabled - Whether 2FA is active +/// @property memberships - Organizations the user belongs to +/// @property notificationSettings - User's notification preferences +model User { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + email String @unique + emailVerified DateTime? @map(name: "email_verified") + + imageUrl String? + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + backupCodes String? + password String? + identityProvider IdentityProvider @default(email) + identityProviderAccountId String? + memberships Membership[] + accounts Account[] + responseNotes ResponseNote[] + groupId String? + invitesCreated Invite[] @relation("inviteCreatedBy") + invitesAccepted Invite[] @relation("inviteAcceptedBy") + role Role? + objective Objective? + /// [UserNotificationSettings] + notificationSettings Json @default("{}") + /// [Locale] + locale String @default("en-US") + surveys Survey[] + teamUsers TeamUser[] + lastLoginAt DateTime? + isActive Boolean @default(true) + + @@index([email]) +} + +/// Maps a short URL to its full destination. +/// Used for creating memorable, shortened URLs for surveys. +/// +/// @property id - Short identifier/slug for the URL +/// @property url - The full destination URL +model ShortUrl { + id String @id // generate nanoId in service + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + url String @unique +} + +/// Defines a segment of contacts based on attributes. +/// Used for targeting surveys to specific user groups. +/// +/// @property id - Unique identifier for the segment +/// @property title - Display name of the segment +/// @property filters - Rules defining the segment +/// @property isPrivate - Whether the segment is private +/// @property environment - The environment this segment belongs to +model Segment { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + title String + description String? + isPrivate Boolean @default(true) + /// [SegmentFilter] + filters Json @default("[]") + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + surveys Survey[] + + @@unique([environmentId, title]) + @@index([environmentId]) +} + +/// Represents a supported language in the system. +/// Used for multilingual survey support. +/// +/// @property id - Unique identifier for the language +/// @property code - Language code (e.g., 'en-US') +/// @property alias - Optional friendly name +/// @property project - The project this language is enabled for +model Language { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + code String + alias String? + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + projectId String + surveyLanguages SurveyLanguage[] + + @@unique([projectId, code]) +} + +/// Links surveys to their supported languages. +/// Manages which languages are available for each survey. +/// +/// @property language - The supported language +/// @property survey - The associated survey +/// @property default - Whether this is the default language +/// @property enabled - Whether this language is currently active +model SurveyLanguage { + language Language @relation(fields: [languageId], references: [id], onDelete: Cascade) + languageId String + surveyId String + survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade) + default Boolean @default(false) + enabled Boolean @default(true) + + @@id([languageId, surveyId]) + @@index([surveyId]) + @@index([languageId]) +} + +enum InsightCategory { + featureRequest + complaint + praise + other +} + +/// Stores analyzed insights from survey responses. +/// Used for tracking patterns and extracting meaningful information. +/// +/// @property id - Unique identifier for the insight +/// @property category - Type of insight (feature request, complaint, etc.) +/// @property title - Summary of the insight +/// @property description - Detailed explanation +/// @property vector - Embedding vector for similarity search +model Insight { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + category InsightCategory + title String + description String + vector Unsupported("vector(512)")? + documentInsights DocumentInsight[] +} + +/// Links insights to source documents. +/// Enables tracing insights back to original responses. +/// +/// @property document - The source document +/// @property insight - The derived insight +model DocumentInsight { + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + insightId String + insight Insight @relation(fields: [insightId], references: [id], onDelete: Cascade) + + @@id([documentId, insightId]) + @@index([insightId]) +} + +enum Sentiment { + positive + negative + neutral +} + +/// Represents a processed text document from survey responses. +/// Used for analysis and insight generation. +/// +/// @property id - Unique identifier for the document +/// @property survey - The associated survey +/// @property response - The source response +/// @property sentiment - Analyzed sentiment (positive, negative, neutral) +/// @property text - The document content +/// @property vector - Embedding vector for similarity search +model Document { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + surveyId String? + survey Survey? @relation(fields: [surveyId], references: [id], onDelete: Cascade) + responseId String? + response Response? @relation(fields: [responseId], references: [id], onDelete: Cascade) + questionId String? + sentiment Sentiment + isSpam Boolean + text String + vector Unsupported("vector(512)")? + documentInsights DocumentInsight[] + + @@unique([responseId, questionId]) + @@index([createdAt]) +} + +/// Represents a team within an organization. +/// Enables group-based access control and collaboration. +/// +/// @property id - Unique identifier for the team +/// @property name - Display name of the team +/// @property organization - The parent organization +/// @property teamUsers - Users who are part of this team +/// @property projectTeams - Projects this team has access to +model Team { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + teamUsers TeamUser[] + projectTeams ProjectTeam[] + + @@unique([organizationId, name]) +} + +enum TeamUserRole { + admin + contributor +} + +/// Links users to teams with specific roles. +/// Manages team membership and permissions. +/// +/// @property team - The associated team +/// @property user - The team member +/// @property role - User's role within the team +model TeamUser { + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + teamId String + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role TeamUserRole + + @@id([teamId, userId]) + @@index([userId]) +} + +enum ProjectTeamPermission { + read + readWrite + manage +} + +/// Defines team access to specific projects. +/// Manages project-level permissions for teams. +/// +/// @property project - The accessed project +/// @property team - The team receiving access +/// @property permission - Level of access granted +model ProjectTeam { + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + teamId String + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + permission ProjectTeamPermission @default(read) + + @@id([projectId, teamId]) + @@index([teamId]) +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 00000000..cb75d025 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "e2e", + "version": "3.0.0-alpha.7", + "private": true, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@zenstackhq/testtools": "workspace:*" + } +} diff --git a/tests/e2e/trigger.dev/schema.zmodel b/tests/e2e/trigger.dev/schema.zmodel new file mode 100644 index 00000000..0975ebb5 --- /dev/null +++ b/tests/e2e/trigger.dev/schema.zmodel @@ -0,0 +1,2084 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") +} + +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-1.1.x"] + previewFeatures = ["tracing", "metrics"] +} + +model User { + id String @id @default(cuid()) + email String @unique + + authenticationMethod AuthenticationMethod + authenticationProfile Json? + authenticationExtraParams Json? + authIdentifier String? @unique + + displayName String? + name String? + avatarUrl String? + + admin Boolean @default(false) + + /// Preferences for the dashboard + dashboardPreferences Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// @deprecated + isOnCloudWaitlist Boolean @default(false) + /// @deprecated + featureCloud Boolean @default(false) + /// @deprecated + isOnHostedRepoWaitlist Boolean @default(false) + + marketingEmails Boolean @default(true) + confirmedBasicDetails Boolean @default(false) + + referralSource String? + + orgMemberships OrgMember[] + sentInvites OrgMemberInvite[] + + invitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id]) + invitationCodeId String? + personalAccessTokens PersonalAccessToken[] + deployments WorkerDeployment[] +} + +// @deprecated This model is no longer used as the Cloud is out of private beta +// Leaving it here for now for historical reasons +model InvitationCode { + id String @id @default(cuid()) + code String @unique + + users User[] + + createdAt DateTime @default(now()) +} + +enum AuthenticationMethod { + GITHUB + MAGIC_LINK +} + +/// Used to generate PersonalAccessTokens, they're one-time use +model AuthorizationCode { + id String @id @default(cuid()) + + code String @unique + + personalAccessToken PersonalAccessToken? @relation(fields: [personalAccessTokenId], references: [id], onDelete: Cascade, onUpdate: Cascade) + personalAccessTokenId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Used by User's to perform API actions +model PersonalAccessToken { + id String @id @default(cuid()) + + /// If generated by the CLI this will be "cli", otherwise user-provided + name String + + /// This is the token encrypted using the ENCRYPTION_KEY + encryptedToken Json + + /// This is shown in the UI, with ******** + obfuscatedToken String + + /// This is used to find the token in the database + hashedToken String @unique + + user User @relation(fields: [userId], references: [id]) + userId String + + revokedAt DateTime? + lastAccessedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + authorizationCodes AuthorizationCode[] +} + +model Organization { + id String @id @default(cuid()) + slug String @unique + title String + + maximumExecutionTimePerRunInMs Int @default(900000) // 15 minutes + maximumConcurrencyLimit Int @default(10) + /// This is deprecated and will be removed in the future + maximumSchedulesLimit Int @default(5) + + maximumDevQueueSize Int? + maximumDeployedQueueSize Int? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + companySize String? + + avatar Json? + + runsEnabled Boolean @default(true) + + v3Enabled Boolean @default(false) + + /// @deprecated + v2Enabled Boolean @default(false) + /// @deprecated + v2MarqsEnabled Boolean @default(false) + /// @deprecated + hasRequestedV3 Boolean @default(false) + + environments RuntimeEnvironment[] + + apiRateLimiterConfig Json? + realtimeRateLimiterConfig Json? + + projects Project[] + members OrgMember[] + invites OrgMemberInvite[] + organizationIntegrations OrganizationIntegration[] + workerGroups WorkerInstanceGroup[] + workerInstances WorkerInstance[] + executionSnapshots TaskRunExecutionSnapshot[] +} + +model OrgMember { + id String @id @default(cuid()) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId String + + role OrgMemberRole @default(MEMBER) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environments RuntimeEnvironment[] + + @@unique([organizationId, userId]) +} + +enum OrgMemberRole { + ADMIN + MEMBER +} + +model OrgMemberInvite { + id String @id @default(cuid()) + token String @unique @default(cuid()) + email String + role OrgMemberRole @default(MEMBER) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + inviter User @relation(fields: [inviterId], references: [id], onDelete: Cascade, onUpdate: Cascade) + inviterId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, email]) +} + +model RuntimeEnvironment { + id String @id @default(cuid()) + slug String + apiKey String @unique + + /// @deprecated was for v2 + pkApiKey String @unique + + type RuntimeEnvironmentType @default(DEVELOPMENT) + + // Preview branches + /// If true, this environment has branches and is treated differently in the dashboard/API + isBranchableEnvironment Boolean @default(false) + branchName String? + parentEnvironment RuntimeEnvironment? @relation("parentEnvironment", fields: [parentEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + parentEnvironmentId String? + childEnvironments RuntimeEnvironment[] @relation("parentEnvironment") + + // This is GitMeta type + git Json? + + /// When set API calls will fail + archivedAt DateTime? + + ///A memorable code for the environment + shortcode String + + maximumConcurrencyLimit Int @default(5) + paused Boolean @default(false) + + autoEnableInternalSources Boolean @default(true) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + //when the org member is deleted, it will keep the environment but set it to null + orgMember OrgMember? @relation(fields: [orgMemberId], references: [id], onDelete: SetNull, onUpdate: Cascade) + orgMemberId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tunnelId String? + + backgroundWorkers BackgroundWorker[] + backgroundWorkerTasks BackgroundWorkerTask[] + taskRuns TaskRun[] + taskQueues TaskQueue[] + batchTaskRuns BatchTaskRun[] + environmentVariableValues EnvironmentVariableValue[] + checkpoints Checkpoint[] + workerDeployments WorkerDeployment[] + workerDeploymentPromotions WorkerDeploymentPromotion[] + taskRunAttempts TaskRunAttempt[] + CheckpointRestoreEvent CheckpointRestoreEvent[] + taskScheduleInstances TaskScheduleInstance[] + alerts ProjectAlert[] + + sessions RuntimeEnvironmentSession[] + currentSession RuntimeEnvironmentSession? @relation("currentSession", fields: [currentSessionId], references: [id], onDelete: SetNull, onUpdate: Cascade) + currentSessionId String? + taskRunNumberCounter TaskRunNumberCounter[] + taskRunCheckpoints TaskRunCheckpoint[] + waitpoints Waitpoint[] + workerInstances WorkerInstance[] + executionSnapshots TaskRunExecutionSnapshot[] + waitpointTags WaitpointTag[] + + @@unique([projectId, slug, orgMemberId]) + @@unique([projectId, shortcode]) + @@index([parentEnvironmentId]) + @@index([projectId]) +} + +enum RuntimeEnvironmentType { + PRODUCTION + STAGING + DEVELOPMENT + PREVIEW +} + +model Project { + id String @id @default(cuid()) + slug String @unique + name String + + externalRef String @unique + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + version ProjectVersion @default(V2) + engine RunEngineVersion @default(V1) + + builderProjectId String? + + workerGroups WorkerInstanceGroup[] + workers WorkerInstance[] + + defaultWorkerGroup WorkerInstanceGroup? @relation("ProjectDefaultWorkerGroup", fields: [defaultWorkerGroupId], references: [id]) + defaultWorkerGroupId String? + + environments RuntimeEnvironment[] + backgroundWorkers BackgroundWorker[] + backgroundWorkerTasks BackgroundWorkerTask[] + taskRuns TaskRun[] + runTags TaskRunTag[] + taskQueues TaskQueue[] + environmentVariables EnvironmentVariable[] + checkpoints Checkpoint[] + WorkerDeployment WorkerDeployment[] + CheckpointRestoreEvent CheckpointRestoreEvent[] + taskSchedules TaskSchedule[] + alertChannels ProjectAlertChannel[] + alerts ProjectAlert[] + alertStorages ProjectAlertStorage[] + bulkActionGroups BulkActionGroup[] + BackgroundWorkerFile BackgroundWorkerFile[] + waitpoints Waitpoint[] + taskRunWaitpoints TaskRunWaitpoint[] + taskRunCheckpoints TaskRunCheckpoint[] + executionSnapshots TaskRunExecutionSnapshot[] + waitpointTags WaitpointTag[] +} + +enum ProjectVersion { + V2 + V3 +} + +model SecretReference { + id String @id @default(cuid()) + key String @unique + provider SecretStoreProvider @default(DATABASE) + + environmentVariableValues EnvironmentVariableValue[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + OrganizationIntegration OrganizationIntegration[] +} + +enum SecretStoreProvider { + DATABASE + AWS_PARAM_STORE +} + +model SecretStore { + key String @unique + value Json + version String @default("1") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([key(ops: raw("text_pattern_ops"))], type: BTree) +} + +model DataMigration { + id String @id @default(cuid()) + name String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + completedAt DateTime? +} + +// ==================================================== +// v3 Models +// ==================================================== +model BackgroundWorker { + id String @id @default(cuid()) + + friendlyId String @unique + + engine RunEngineVersion @default(V1) + + contentHash String + sdkVersion String @default("unknown") + cliVersion String @default("unknown") + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + + version String + metadata Json + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tasks BackgroundWorkerTask[] + attempts TaskRunAttempt[] + lockedRuns TaskRun[] + files BackgroundWorkerFile[] + queues TaskQueue[] + + deployment WorkerDeployment? + + workerGroup WorkerInstanceGroup? @relation(fields: [workerGroupId], references: [id], onDelete: SetNull, onUpdate: Cascade) + workerGroupId String? + + supportsLazyAttempts Boolean @default(false) + + @@unique([projectId, runtimeEnvironmentId, version]) + @@index([runtimeEnvironmentId]) + // Get the latest worker for a given environment + @@index([runtimeEnvironmentId, createdAt(sort: Desc)]) +} + +model BackgroundWorkerFile { + id String @id @default(cuid()) + + friendlyId String @unique + + filePath String + contentHash String + contents Bytes + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + backgroundWorkers BackgroundWorker[] + + tasks BackgroundWorkerTask[] + + createdAt DateTime @default(now()) + + @@unique([projectId, contentHash]) +} + +model BackgroundWorkerTask { + id String @id @default(cuid()) + slug String + + description String? + + friendlyId String @unique + + filePath String + exportName String? + + worker BackgroundWorker @relation(fields: [workerId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workerId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + file BackgroundWorkerFile? @relation(fields: [fileId], references: [id], onDelete: Cascade, onUpdate: Cascade) + fileId String? + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + attempts TaskRunAttempt[] + runs TaskRun[] + + queueConfig Json? + retryConfig Json? + machineConfig Json? + + queueId String? + queue TaskQueue? @relation(fields: [queueId], references: [id], onDelete: SetNull, onUpdate: Cascade) + + maxDurationInSeconds Int? + + triggerSource TaskTriggerSource @default(STANDARD) + + @@unique([workerId, slug]) + // Quick lookup of task identifiers + @@index([projectId, slug]) + @@index([runtimeEnvironmentId, projectId]) +} + +enum TaskTriggerSource { + STANDARD + SCHEDULED +} + +model TaskRun { + id String @id @default(cuid()) + + number Int @default(0) + friendlyId String @unique + + engine RunEngineVersion @default(V1) + + status TaskRunStatus @default(PENDING) + statusReason String? + + idempotencyKey String? + idempotencyKeyExpiresAt DateTime? + taskIdentifier String + + isTest Boolean @default(false) + + payload String + payloadType String @default("application/json") + context Json? + traceContext Json? + + traceId String + spanId String + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + + environmentType RuntimeEnvironmentType? + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + organizationId String? + + // The specific queue this run is in + queue String + // The queueId is set when the run is locked to a specific queue + lockedQueueId String? + + /// The main queue that this run is part of + workerQueue String @default("main") @map("masterQueue") + + /// @deprecated + secondaryMasterQueue String? + + /// From engine v2+ this will be defined after a run has been dequeued (starting at 1) + attemptNumber Int? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + attempts TaskRunAttempt[] @relation("attempts") + tags TaskRunTag[] + + /// Denormized column that holds the raw tags + runTags String[] + + /// Denormalized version of the background worker task + taskVersion String? + sdkVersion String? + cliVersion String? + + checkpoints Checkpoint[] + + /// startedAt marks the point at which a run is dequeued from MarQS + startedAt DateTime? + /// executedAt is set when the first attempt is about to execute + executedAt DateTime? + completedAt DateTime? + machinePreset String? + + usageDurationMs Int @default(0) + costInCents Float @default(0) + baseCostInCents Float @default(0) + + lockedAt DateTime? + lockedBy BackgroundWorkerTask? @relation(fields: [lockedById], references: [id]) + lockedById String? + + lockedToVersion BackgroundWorker? @relation(fields: [lockedToVersionId], references: [id]) + lockedToVersionId String? + + /// The "priority" of the run. This is just a negative offset in ms for the queue timestamp + /// E.g. a value of 60_000 would put the run into the queue 60s ago. + priorityMs Int @default(0) + + concurrencyKey String? + + delayUntil DateTime? + queuedAt DateTime? + ttl String? + expiredAt DateTime? + maxAttempts Int? + + /// optional token that can be used to authenticate the task run + oneTimeUseToken String? + + ///When this run is finished, the waitpoint will be marked as completed + associatedWaitpoint Waitpoint? @relation("CompletingRun") + + ///If there are any blocked waitpoints, the run won't be executed + blockedByWaitpoints TaskRunWaitpoint[] + + /// All waitpoints that blocked this run at some point, used for display purposes + connectedWaitpoints Waitpoint[] @relation("WaitpointRunConnections") + + /// Where the logs are stored + taskEventStore String @default("taskEvent") + + queueTimestamp DateTime? + + batchItems BatchTaskRunItem[] + dependency TaskRunDependency? + CheckpointRestoreEvent CheckpointRestoreEvent[] + executionSnapshots TaskRunExecutionSnapshot[] + + alerts ProjectAlert[] + + scheduleInstanceId String? + scheduleId String? + + sourceBulkActionItems BulkActionItem[] @relation("SourceActionItemRun") + destinationBulkActionItems BulkActionItem[] @relation("DestinationActionItemRun") + + logsDeletedAt DateTime? + + /// This represents the original task that that was triggered outside of a Trigger.dev task + rootTaskRun TaskRun? @relation("TaskRootRun", fields: [rootTaskRunId], references: [id], onDelete: SetNull, onUpdate: NoAction) + rootTaskRunId String? + + /// The root run will have a list of all the descendant runs, children, grand children, etc. + descendantRuns TaskRun[] @relation("TaskRootRun") + + /// The immediate parent run of this task run + parentTaskRun TaskRun? @relation("TaskParentRun", fields: [parentTaskRunId], references: [id], onDelete: SetNull, onUpdate: NoAction) + parentTaskRunId String? + + /// The immediate child runs of this task run + childRuns TaskRun[] @relation("TaskParentRun") + + /// The immediate parent attempt of this task run + parentTaskRunAttempt TaskRunAttempt? @relation("TaskParentRunAttempt", fields: [parentTaskRunAttemptId], references: [id], onDelete: SetNull, onUpdate: NoAction) + parentTaskRunAttemptId String? + + /// The batch run that this task run is a part of + batch BatchTaskRun? @relation(fields: [batchId], references: [id], onDelete: SetNull, onUpdate: NoAction) + batchId String? + + /// whether or not the task run was created because of a triggerAndWait for batchTriggerAndWait + resumeParentOnCompletion Boolean @default(false) + + /// The depth of this task run in the task run hierarchy + depth Int @default(0) + + /// The span ID of the "trigger" span in the parent task run + parentSpanId String? + + /// Holds the state of the run chain for deadlock detection + runChainState Json? + + /// seed run metadata + seedMetadata String? + seedMetadataType String @default("application/json") + + /// Run metadata + metadata String? + metadataType String @default("application/json") + metadataVersion Int @default(1) + + /// Run output + output String? + outputType String @default("application/json") + + /// Run error + error Json? + + maxDurationInSeconds Int? + + @@unique([oneTimeUseToken]) + @@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey]) + // Finding child runs + @@index([parentTaskRunId]) + // Finding ancestor runs + @@index([rootTaskRunId]) + //Schedules + @@index([scheduleId]) + // Run page inspector + @@index([spanId]) + @@index([parentSpanId]) + // Schedule list page + @@index([scheduleId, createdAt(sort: Desc)]) + // Finding runs in a batch + @@index([runTags(ops: ArrayOps)], type: Gin) + @@index([runtimeEnvironmentId, batchId]) + // This will include the createdAt index to help speed up the run list page + @@index([runtimeEnvironmentId, id(sort: Desc)]) + @@index([runtimeEnvironmentId, createdAt(sort: Desc)]) + @@index([createdAt], type: Brin) + @@index([status, runtimeEnvironmentId, createdAt, id(sort: Desc)]) +} + +enum TaskRunStatus { + /// + /// NON-FINAL STATUSES + /// + + /// Task has been scheduled to run in the future + DELAYED + /// Task is waiting to be executed by a worker + PENDING + + /// The run is pending a version update because it cannot execute without additional information (task, queue, etc.). Replaces WAITING_FOR_DEPLOY + PENDING_VERSION + + /// Task hasn't been deployed yet but is waiting to be executed. Deprecated in favor of PENDING_VERSION + WAITING_FOR_DEPLOY + + /// Task is currently being executed by a worker + EXECUTING + + /// Task has been paused by the system, and will be resumed by the system + WAITING_TO_RESUME + + /// Task has failed and is waiting to be retried + RETRYING_AFTER_FAILURE + + /// Task has been paused by the user, and can be resumed by the user + PAUSED + + /// + /// FINAL STATUSES + /// + + /// Task has been canceled by the user + CANCELED + + /// Task was interrupted during execution, mostly this happens in development environments + INTERRUPTED + + /// Task has been completed successfully + COMPLETED_SUCCESSFULLY + + /// Task has been completed with errors + COMPLETED_WITH_ERRORS + + /// Task has failed to complete, due to an error in the system + SYSTEM_FAILURE + + /// Task has crashed and won't be retried, most likely the worker ran out of resources, e.g. memory or storage + CRASHED + + /// Task reached the ttl without being executed + EXPIRED + + /// Task has been timed out when using maxDuration + TIMED_OUT +} + +enum RunEngineVersion { + /// The original version that uses marqs v1 and Graphile + V1 + V2 +} + +/// Used by the RunEngine during TaskRun execution +/// It has the required information to transactionally progress a run through states, +/// and prevent side effects like heartbeats failing a run that has progressed. +/// It is optimised for performance and is designed to be cleared at some point, +/// so there are no cascading relationships to other models. +model TaskRunExecutionSnapshot { + id String @id @default(cuid()) + + /// This should always be 2+ (V1 didn't use the run engine or snapshots) + engine RunEngineVersion @default(V2) + + /// The execution status + executionStatus TaskRunExecutionStatus + /// For debugging + description String + + /// We store invalid snapshots as a record of the run state when we tried to move + isValid Boolean @default(true) + error String? + + /// The previous snapshot ID + previousSnapshotId String? + + /// Run + runId String + run TaskRun @relation(fields: [runId], references: [id]) + runStatus TaskRunStatus + + // Batch + batchId String? + batch BatchTaskRun? @relation(fields: [batchId], references: [id]) + + /// This is the current run attempt number. Users can define how many attempts they want for a run. + attemptNumber Int? + + /// Environment + environmentId String + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id]) + environmentType RuntimeEnvironmentType + + projectId String + project Project @relation(fields: [projectId], references: [id]) + + organizationId String + organization Organization @relation(fields: [organizationId], references: [id]) + + /// Waitpoints that have been completed for this execution + completedWaitpoints Waitpoint[] @relation("completedWaitpoints") + + /// An array of waitpoint IDs in the correct order, used for batches + completedWaitpointOrder String[] + + /// Checkpoint + checkpointId String? + checkpoint TaskRunCheckpoint? @relation(fields: [checkpointId], references: [id]) + + /// Worker + workerId String? + worker WorkerInstance? @relation(fields: [workerId], references: [id]) + + runnerId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + lastHeartbeatAt DateTime? + + /// Metadata used by various systems in the run engine + metadata Json? + + /// Used to get the latest valid snapshot quickly + @@index([runId, isValid, createdAt(sort: Desc)]) +} + +enum TaskRunExecutionStatus { + /// Run has been created + RUN_CREATED + /// Run is in the RunQueue + QUEUED + /// Run is in the RunQueue, and is also executing. This happens when a run is continued cannot reacquire concurrency + QUEUED_EXECUTING + /// Run has been pulled from the queue, but isn't executing yet + PENDING_EXECUTING + /// Run is executing on a worker + EXECUTING + /// Run is executing on a worker but is waiting for waitpoints to complete + EXECUTING_WITH_WAITPOINTS + /// Run has been suspended and may be waiting for waitpoints to complete before resuming + SUSPENDED + /// Run has been scheduled for cancellation + PENDING_CANCEL + /// Run is finished (success of failure) + FINISHED +} + +model TaskRunCheckpoint { + id String @id @default(cuid()) + + friendlyId String @unique + + type TaskRunCheckpointType + location String + imageRef String? + reason String? + metadata String? + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + + executionSnapshot TaskRunExecutionSnapshot[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum TaskRunCheckpointType { + DOCKER + KUBERNETES +} + +/// A Waitpoint blocks a run from continuing until it's completed +/// If there's a waitpoint blocking a run, it shouldn't be in the queue +model Waitpoint { + id String @id @default(cuid()) + + friendlyId String @unique + + type WaitpointType + status WaitpointStatus @default(PENDING) + + completedAt DateTime? + + /// If it's an Event type waitpoint, this is the event. It can also be provided for the DATETIME type + idempotencyKey String + /// If this is true then we can show it in the dashboard/return it from the SDK + userProvidedIdempotencyKey Boolean + + /// If there's a user provided idempotency key, this is the time it expires at + idempotencyKeyExpiresAt DateTime? + + /// If an idempotencyKey is no longer active, we store it here and generate a new one for the idempotencyKey field. + /// Clearing an idempotencyKey is useful for debounce or cancelling child runs. + /// This is a workaround because Prisma doesn't support partial indexes. + inactiveIdempotencyKey String? + + /// If it's a RUN type waitpoint, this is the associated run + completedByTaskRunId String? @unique + completedByTaskRun TaskRun? @relation("CompletingRun", fields: [completedByTaskRunId], references: [id], onDelete: SetNull) + + /// If it's a DATETIME type waitpoint, this is the date. + /// If it's a MANUAL waitpoint, this can be set as the `timeout`. + completedAfter DateTime? + + /// If it's a BATCH type waitpoint, this is the associated batch + completedByBatchId String? + completedByBatch BatchTaskRun? @relation(fields: [completedByBatchId], references: [id], onDelete: SetNull) + + /// The runs this waitpoint is blocking + blockingTaskRuns TaskRunWaitpoint[] + + /// All runs that have ever been blocked by this waitpoint, used for display purposes + connectedRuns TaskRun[] @relation("WaitpointRunConnections") + + /// When a waitpoint is complete + completedExecutionSnapshots TaskRunExecutionSnapshot[] @relation("completedWaitpoints") + + /// When completed, an output can be stored here + output String? + outputType String @default("application/json") + outputIsError Boolean @default(false) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// Denormized column that holds the raw tags + /// Denormalized column that holds the raw tags + tags String[] + + /// Quickly find an idempotent waitpoint + @@unique([environmentId, idempotencyKey]) + /// Quickly find a batch waitpoint + @@index([completedByBatchId]) + /// Used on the Waitpoint dashboard pages + /// Time period filtering + @@index([environmentId, type, createdAt(sort: Desc)]) + /// Status filtering + @@index([environmentId, type, status]) +} + +enum WaitpointType { + RUN + DATETIME + MANUAL + BATCH +} + +enum WaitpointStatus { + PENDING + COMPLETED +} + +model TaskRunWaitpoint { + id String @id @default(cuid()) + + taskRun TaskRun @relation(fields: [taskRunId], references: [id]) + taskRunId String + + waitpoint Waitpoint @relation(fields: [waitpointId], references: [id]) + waitpointId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + /// This span id is completed when the waitpoint is completed. This is used with cached runs (idempotent) + spanIdToComplete String? + + //associated batch + batchId String? + batch BatchTaskRun? @relation(fields: [batchId], references: [id]) + //if there's an associated batch and this isn't set it's for the entire batch + //if it is set, it's a specific run in the batch + batchIndex Int? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// There are two constraints, the one below and also one that Prisma doesn't support + /// The second one implemented in SQL only prevents a TaskRun + Waitpoint with a null batchIndex + @@unique([taskRunId, waitpointId, batchIndex]) + @@index([taskRunId]) + @@index([waitpointId]) +} + +model WaitpointTag { + id String @id @default(cuid()) + name String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + createdAt DateTime @default(now()) + + @@unique([environmentId, name]) +} + +model FeatureFlag { + id String @id @default(cuid()) + + key String @unique + value Json? +} + +model WorkerInstance { + id String @id @default(cuid()) + + /// For example "worker-1" + name String + + /// If managed, it will default to the name, e.g. "worker-1" + /// If unmanged, it will be prefixed with the deployment ID e.g. "deploy-123-worker-1" + resourceIdentifier String + + metadata Json? + + workerGroup WorkerInstanceGroup @relation(fields: [workerGroupId], references: [id]) + workerGroupId String + + TaskRunExecutionSnapshot TaskRunExecutionSnapshot[] + + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String? + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String? + + environment RuntimeEnvironment? @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String? + + deployment WorkerDeployment? @relation(fields: [deploymentId], references: [id], onDelete: SetNull, onUpdate: Cascade) + deploymentId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + lastDequeueAt DateTime? + lastHeartbeatAt DateTime? + + @@unique([workerGroupId, resourceIdentifier]) +} + +enum WorkerInstanceGroupType { + MANAGED + UNMANAGED +} + +model WorkerInstanceGroup { + id String @id @default(cuid()) + type WorkerInstanceGroupType + + /// For example "us-east-1" + name String + + /// If managed, it will default to the name, e.g. "us-east-1" + /// If unmanged, it will be prefixed with the project ID e.g. "project_1-us-east-1" + masterQueue String @unique + + description String? + hidden Boolean @default(false) + + token WorkerGroupToken @relation(fields: [tokenId], references: [id], onDelete: Cascade, onUpdate: Cascade) + tokenId String @unique + + workers WorkerInstance[] + backgroundWorkers BackgroundWorker[] + + defaultForProjects Project[] @relation("ProjectDefaultWorkerGroup") + + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String? + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model WorkerGroupToken { + id String @id @default(cuid()) + + tokenHash String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workerGroup WorkerInstanceGroup? +} + +model TaskRunTag { + id String @id @default(cuid()) + name String + + friendlyId String @unique + + runs TaskRun[] + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + createdAt DateTime @default(now()) + + @@unique([projectId, name]) + //Makes run filtering by tag faster + @@index([name, id]) +} + +/// This is used for triggerAndWait and batchTriggerAndWait. The taskRun is the child task, it points at a parent attempt or a batch +model TaskRunDependency { + id String @id @default(cuid()) + + /// The child run + taskRun TaskRun @relation(fields: [taskRunId], references: [id], onDelete: Cascade, onUpdate: Cascade) + taskRunId String @unique + + checkpointEvent CheckpointRestoreEvent? @relation(fields: [checkpointEventId], references: [id], onDelete: Cascade, onUpdate: Cascade) + checkpointEventId String? @unique + + /// An attempt that is dependent on this task run. + dependentAttempt TaskRunAttempt? @relation(fields: [dependentAttemptId], references: [id]) + dependentAttemptId String? + + /// A batch run that is dependent on this task run + dependentBatchRun BatchTaskRun? @relation("dependentBatchRun", fields: [dependentBatchRunId], references: [id]) + dependentBatchRunId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + resumedAt DateTime? + + @@index([dependentAttemptId]) + @@index([dependentBatchRunId]) +} + +/// deprecated, we hadn't included the project id in the unique constraint +model TaskRunCounter { + taskIdentifier String @id + lastNumber Int @default(0) +} + +/// Used for the TaskRun number (e.g. #1,421) +model TaskRunNumberCounter { + id String @id @default(cuid()) + + taskIdentifier String + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + lastNumber Int @default(0) + + @@unique([taskIdentifier, environmentId]) +} + +/// This is not used from engine v2+, attempts use the TaskRunExecutionSnapshot and TaskRun +model TaskRunAttempt { + id String @id @default(cuid()) + number Int @default(0) + + friendlyId String @unique + + taskRun TaskRun @relation("attempts", fields: [taskRunId], references: [id], onDelete: Cascade, onUpdate: Cascade) + taskRunId String + + backgroundWorker BackgroundWorker @relation(fields: [backgroundWorkerId], references: [id], onDelete: Cascade, onUpdate: Cascade) + backgroundWorkerId String + + backgroundWorkerTask BackgroundWorkerTask @relation(fields: [backgroundWorkerTaskId], references: [id], onDelete: Cascade, onUpdate: Cascade) + backgroundWorkerTaskId String + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + + queue TaskQueue @relation(fields: [queueId], references: [id], onDelete: Cascade, onUpdate: Cascade) + queueId String + + status TaskRunAttemptStatus @default(PENDING) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + startedAt DateTime? + completedAt DateTime? + + usageDurationMs Int @default(0) + + error Json? + output String? + outputType String @default("application/json") + + dependencies TaskRunDependency[] + batchDependencies BatchTaskRun[] + + checkpoints Checkpoint[] + batchTaskRunItems BatchTaskRunItem[] + CheckpointRestoreEvent CheckpointRestoreEvent[] + alerts ProjectAlert[] + childRuns TaskRun[] @relation("TaskParentRunAttempt") + + @@unique([taskRunId, number]) + @@index([taskRunId]) +} + +enum TaskRunAttemptStatus { + /// NON-FINAL + PENDING + EXECUTING + PAUSED + /// FINAL + FAILED + CANCELED + COMPLETED +} + +/// This is the unified otel span/log model that will eventually be replaced by clickhouse +model TaskEvent { + id String @id @default(cuid()) + + /// This matches the span name for a trace event, or the log body for a log event + message String + + traceId String + spanId String + parentId String? + tracestate String? + + isError Boolean @default(false) + isPartial Boolean @default(false) + isCancelled Boolean @default(false) + /// deprecated: don't use this, moving this to properties, this now uses TaskEventKind.LOG + isDebug Boolean @default(false) + + serviceName String + serviceNamespace String + + level TaskEventLevel @default(TRACE) + kind TaskEventKind @default(INTERNAL) + status TaskEventStatus @default(UNSET) + + links Json? + events Json? + + /// This is the time the event started in nanoseconds since the epoch + startTime BigInt + + /// This is the duration of the event in nanoseconds + duration BigInt @default(0) + + attemptId String? + attemptNumber Int? + + environmentId String + environmentType RuntimeEnvironmentType + + organizationId String + + projectId String + projectRef String + + runId String + runIsTest Boolean @default(false) + + idempotencyKey String? + + taskSlug String + taskPath String? + taskExportName String? + + workerId String? + workerVersion String? + + queueId String? + queueName String? + + batchId String? + + /// This represents all the span attributes available, like http.status_code, and special attributes like $style.icon, $output, $metadata.payload.userId, as it's used for searching and filtering + properties Json + + /// This represents all span attributes in the $metadata namespace, like $metadata.payload + metadata Json? + + /// This represents all span attributes in the $style namespace, like $style + style Json? + + /// This represents all span attributes in the $output namespace, like $output + output Json? + + /// This represents the mimetype of the output, such as application/json or application/super+json + outputType String? + + payload Json? + payloadType String? + + createdAt DateTime @default(now()) + + // This represents the amount of "usage time" the event took, e.g. the CPU time + usageDurationMs Int @default(0) + usageCostInCents Float @default(0) + + machinePreset String? + machinePresetCpu Float? + machinePresetMemory Float? + machinePresetCentsPerMs Float? + + /// Used on the run page + @@index([traceId]) + /// Used when looking up span events to complete when a run completes + @@index([spanId]) + // Used for getting all logs for a run + @@index([runId]) +} + +enum TaskEventLevel { + TRACE + DEBUG + LOG + INFO + WARN + ERROR +} + +enum TaskEventKind { + UNSPECIFIED + INTERNAL + SERVER + CLIENT + PRODUCER + CONSUMER + UNRECOGNIZED + LOG +} + +enum TaskEventStatus { + UNSET + OK + ERROR + UNRECOGNIZED +} + +model TaskQueue { + id String @id @default(cuid()) + + friendlyId String @unique + + name String + type TaskQueueType @default(VIRTUAL) + + version TaskQueueVersion @default(V1) + orderableName String? + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + + concurrencyLimit Int? + rateLimit Json? + + paused Boolean @default(false) + + /// If true, when a run is paused and waiting for waitpoints to be completed, the run will release the concurrency capacity. + releaseConcurrencyOnWaitpoint Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + attempts TaskRunAttempt[] + tasks BackgroundWorkerTask[] + workers BackgroundWorker[] + + @@unique([runtimeEnvironmentId, name]) +} + +enum TaskQueueType { + VIRTUAL + NAMED +} + +enum TaskQueueVersion { + V1 + V2 +} + +model BatchTaskRun { + id String @id @default(cuid()) + friendlyId String @unique + idempotencyKey String? + idempotencyKeyExpiresAt DateTime? + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + status BatchTaskRunStatus @default(PENDING) + runtimeEnvironmentId String + /// This only includes new runs, not idempotent runs. + runs TaskRun[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // new columns + /// Friendly IDs + runIds String[] @default([]) + runCount Int @default(0) + payload String? + payloadType String @default("application/json") + options Json? + batchVersion String @default("v1") + + //engine v2 + /// Snapshots that reference this batch + executionSnapshots TaskRunExecutionSnapshot[] + /// Specific run blockers, + runsBlocked TaskRunWaitpoint[] + /// Waitpoints that are blocked by this batch. + /// When a Batch is created it blocks execution of the associated parent run (for andWait) + waitpoints Waitpoint[] + + // This is for v3 batches + /// sealed is set to true once no more items can be added to the batch + sealed Boolean @default(false) + sealedAt DateTime? + /// this is the expected number of items in the batch + expectedCount Int @default(0) + /// this is the completed number of items in the batch. once this reaches expectedCount, and the batch is sealed, the batch is considered completed + completedCount Int @default(0) + completedAt DateTime? + resumedAt DateTime? + + /// this is used to be able to "seal" this BatchTaskRun when all of the runs have been triggered asynchronously, and using the "parallel" processing strategy + processingJobsCount Int @default(0) + processingJobsExpectedCount Int @default(0) + + /// optional token that can be used to authenticate the task run + oneTimeUseToken String? + + ///all the below properties are engine v1 only + items BatchTaskRunItem[] + taskIdentifier String? + checkpointEvent CheckpointRestoreEvent? @relation(fields: [checkpointEventId], references: [id], onDelete: Cascade, onUpdate: Cascade) + checkpointEventId String? @unique + dependentTaskAttempt TaskRunAttempt? @relation(fields: [dependentTaskAttemptId], references: [id], onDelete: Cascade, onUpdate: Cascade) + dependentTaskAttemptId String? + runDependencies TaskRunDependency[] @relation("dependentBatchRun") + + @@unique([oneTimeUseToken]) + ///this is used for all engine versions + @@unique([runtimeEnvironmentId, idempotencyKey]) + @@index([dependentTaskAttemptId]) +} + +enum BatchTaskRunStatus { + PENDING + COMPLETED + ABORTED +} + +///Used in engine V1 only +model BatchTaskRunItem { + id String @id @default(cuid()) + + status BatchTaskRunItemStatus @default(PENDING) + + batchTaskRun BatchTaskRun @relation(fields: [batchTaskRunId], references: [id], onDelete: Cascade, onUpdate: Cascade) + batchTaskRunId String + + taskRun TaskRun @relation(fields: [taskRunId], references: [id], onDelete: Cascade, onUpdate: Cascade) + taskRunId String + + taskRunAttempt TaskRunAttempt? @relation(fields: [taskRunAttemptId], references: [id], onDelete: SetNull, onUpdate: Cascade) + taskRunAttemptId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + completedAt DateTime? + + @@unique([batchTaskRunId, taskRunId]) + @@index([taskRunAttemptId], map: "idx_batchtaskrunitem_taskrunattempt") + @@index([taskRunId], map: "idx_batchtaskrunitem_taskrun") +} + +enum BatchTaskRunItemStatus { + PENDING + FAILED + CANCELED + COMPLETED +} + +model EnvironmentVariable { + id String @id @default(cuid()) + friendlyId String @unique + key String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + values EnvironmentVariableValue[] + + @@unique([projectId, key]) +} + +model EnvironmentVariableValue { + id String @id @default(cuid()) + valueReference SecretReference? @relation(fields: [valueReferenceId], references: [id], onDelete: SetNull, onUpdate: Cascade) + valueReferenceId String? + variable EnvironmentVariable @relation(fields: [variableId], references: [id], onDelete: Cascade, onUpdate: Cascade) + variableId String + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + /// If true, the value is secret and cannot be revealed + isSecret Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([variableId, environmentId]) +} + +model Checkpoint { + id String @id @default(cuid()) + + friendlyId String @unique + + type CheckpointType + location String + imageRef String + reason String? + metadata String? + + events CheckpointRestoreEvent[] + + run TaskRun @relation(fields: [runId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runId String + + attempt TaskRunAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade, onUpdate: Cascade) + attemptId String + attemptNumber Int? + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([attemptId]) + @@index([runId]) +} + +enum CheckpointType { + DOCKER + KUBERNETES +} + +model CheckpointRestoreEvent { + id String @id @default(cuid()) + + type CheckpointRestoreEventType + reason String? + metadata String? + + checkpoint Checkpoint @relation(fields: [checkpointId], references: [id], onDelete: Cascade, onUpdate: Cascade) + checkpointId String + + run TaskRun @relation(fields: [runId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runId String + + attempt TaskRunAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade, onUpdate: Cascade) + attemptId String + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + + taskRunDependency TaskRunDependency? + batchTaskRunDependency BatchTaskRun? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([checkpointId]) + @@index([runId]) +} + +enum CheckpointRestoreEventType { + CHECKPOINT + RESTORE +} + +enum WorkerDeploymentType { + MANAGED + UNMANAGED + V1 +} + +model WorkerDeployment { + id String @id @default(cuid()) + + contentHash String + friendlyId String @unique + shortCode String + version String + + imageReference String? + imagePlatform String @default("linux/amd64") + + externalBuildData Json? + + status WorkerDeploymentStatus @default(PENDING) + type WorkerDeploymentType @default(V1) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + worker BackgroundWorker? @relation(fields: [workerId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workerId String? @unique + + triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade) + triggeredById String? + + builtAt DateTime? + deployedAt DateTime? + + failedAt DateTime? + errorData Json? + + // This is GitMeta type + git Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + promotions WorkerDeploymentPromotion[] + alerts ProjectAlert[] + workerInstance WorkerInstance[] + + @@unique([projectId, shortCode]) + @@unique([environmentId, version]) +} + +enum WorkerDeploymentStatus { + PENDING + /// This is the status when the image is being built + BUILDING + /// This is the status when the image is built and we are waiting for the indexing to finish + DEPLOYING + /// This is the status when the image is built and indexed, meaning we have everything we need to deploy + DEPLOYED + FAILED + CANCELED + /// This is the status when the image is built and indexing does not finish in time + TIMED_OUT +} + +model WorkerDeploymentPromotion { + id String @id @default(cuid()) + + /// This is the promotion label, e.g. "current" + label String + + deployment WorkerDeployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + deploymentId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + // Only one promotion per environment can be active at a time + @@unique([environmentId, label]) +} + +///Schedules can be attached to tasks to trigger them on a schedule +model TaskSchedule { + id String @id @default(cuid()) + + type ScheduleType @default(IMPERATIVE) + + ///users see this as `id`. They start with schedule_ + friendlyId String @unique + ///a reference to a task (not a foreign key because it's across versions) + taskIdentifier String + + ///can be provided and we won't create another with the same key + deduplicationKey String @default(cuid()) + userProvidedDeduplicationKey Boolean @default(false) + + ///the CRON pattern + generatorExpression String + generatorDescription String @default("") + generatorType ScheduleGeneratorType @default(CRON) + + /// These are IANA format string, or the default "UTC". E.g. "America/New_York" + timezone String @default("UTC") + + ///Can be provided by the user then accessed inside a run + externalId String? + + ///Instances of the schedule that are active + instances TaskScheduleInstance[] + + lastRunTriggeredAt DateTime? + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + active Boolean @default(true) + + @@unique([projectId, deduplicationKey]) + /// Dashboard list view + @@index([projectId]) + @@index([projectId, createdAt(sort: Desc)]) +} + +enum ScheduleType { + /// defined on your task using the `cron` property + DECLARATIVE + /// explicit calls to the SDK are used to create, or using the dashboard + IMPERATIVE +} + +enum ScheduleGeneratorType { + CRON +} + +///An instance links a schedule with an environment +model TaskScheduleInstance { + id String @id @default(cuid()) + + taskSchedule TaskSchedule @relation(fields: [taskScheduleId], references: [id], onDelete: Cascade, onUpdate: Cascade) + taskScheduleId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + active Boolean @default(true) + + lastScheduledTimestamp DateTime? + nextScheduledTimestamp DateTime? + + //you can only have a schedule attached to each environment once + @@unique([taskScheduleId, environmentId]) +} + +model RuntimeEnvironmentSession { + id String @id @default(cuid()) + + ipAddress String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + disconnectedAt DateTime? + + currentEnvironments RuntimeEnvironment[] @relation("currentSession") +} + +model ProjectAlertChannel { + id String @id @default(cuid()) + + friendlyId String @unique + + ///can be provided and we won't create another with the same key + deduplicationKey String @default(cuid()) + userProvidedDeduplicationKey Boolean @default(false) + + integration OrganizationIntegration? @relation(fields: [integrationId], references: [id], onDelete: SetNull, onUpdate: Cascade) + integrationId String? + + enabled Boolean @default(true) + + type ProjectAlertChannelType + name String + properties Json + alertTypes ProjectAlertType[] + environmentTypes RuntimeEnvironmentType[] @default([STAGING, PRODUCTION]) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + alerts ProjectAlert[] + alertStorages ProjectAlertStorage[] + + @@unique([projectId, deduplicationKey]) +} + +enum ProjectAlertChannelType { + EMAIL + SLACK + WEBHOOK +} + +model ProjectAlert { + id String @id @default(cuid()) + friendlyId String @unique + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + + channel ProjectAlertChannel @relation(fields: [channelId], references: [id], onDelete: Cascade, onUpdate: Cascade) + channelId String + + status ProjectAlertStatus @default(PENDING) + + type ProjectAlertType + + taskRunAttempt TaskRunAttempt? @relation(fields: [taskRunAttemptId], references: [id], onDelete: Cascade, onUpdate: Cascade) + taskRunAttemptId String? + + taskRun TaskRun? @relation(fields: [taskRunId], references: [id], onDelete: Cascade, onUpdate: Cascade) + taskRunId String? + + workerDeployment WorkerDeployment? @relation(fields: [workerDeploymentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + workerDeploymentId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum ProjectAlertType { + TASK_RUN + /// deprecated, we don't send new alerts for this type + TASK_RUN_ATTEMPT + DEPLOYMENT_FAILURE + DEPLOYMENT_SUCCESS +} + +enum ProjectAlertStatus { + PENDING + SENT + FAILED +} + +model ProjectAlertStorage { + id String @id @default(cuid()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + alertChannel ProjectAlertChannel @relation(fields: [alertChannelId], references: [id], onDelete: Cascade, onUpdate: Cascade) + alertChannelId String + + alertType ProjectAlertType + + storageId String + storageData Json + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model OrganizationIntegration { + id String @id @default(cuid()) + + friendlyId String @unique + + service IntegrationService + + integrationData Json + + tokenReference SecretReference @relation(fields: [tokenReferenceId], references: [id], onDelete: Cascade, onUpdate: Cascade) + tokenReferenceId String + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + alertChannels ProjectAlertChannel[] +} + +enum IntegrationService { + SLACK +} + +/// Bulk actions, like canceling and replaying runs +model BulkActionGroup { + id String @id @default(cuid()) + + friendlyId String @unique + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + type BulkActionType + items BulkActionItem[] + + /// When the group is created it's pending. After we've processed all the items it's completed. This does not mean the associated runs are completed. + status BulkActionStatus @default(PENDING) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum BulkActionType { + /// Cancels existing runs. This populates the destination runs. + CANCEL + /// Replays existing runs. The original runs go as source runs, and the new runs go as destination runs. + REPLAY +} + +enum BulkActionStatus { + PENDING + COMPLETED +} + +model BulkActionItem { + id String @id @default(cuid()) + + friendlyId String @unique + + group BulkActionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade, onUpdate: Cascade) + groupId String + + type BulkActionType + + /// When the item is created it's pending. After we've processed the item it's completed. This does not mean the associated runs are completed. + status BulkActionItemStatus @default(PENDING) + + /// The run that is the source of the action, e.g. when replaying this is the original run + sourceRun TaskRun @relation("SourceActionItemRun", fields: [sourceRunId], references: [id], onDelete: Cascade, onUpdate: Cascade) + sourceRunId String + + /// The run that's a result of the action, this will be set when the run has been created + destinationRun TaskRun? @relation("DestinationActionItemRun", fields: [destinationRunId], references: [id], onDelete: Cascade, onUpdate: Cascade) + destinationRunId String? + + error String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum BulkActionItemStatus { + PENDING + COMPLETED + FAILED +} + +model RealtimeStreamChunk { + id String @id @default(cuid()) + + key String + value String + + sequence Int + + runId String + + createdAt DateTime @default(now()) + + @@index([runId]) + @@index([createdAt]) +} + +/// This is the unified otel span/log model that will eventually be replaced by clickhouse +model TaskEventPartitioned { + id String @default(cuid()) + /// This matches the span name for a trace event, or the log body for a log event + message String + + traceId String + spanId String + parentId String? + tracestate String? + + isError Boolean @default(false) + isPartial Boolean @default(false) + isCancelled Boolean @default(false) + + serviceName String + serviceNamespace String + + level TaskEventLevel @default(TRACE) + kind TaskEventKind @default(INTERNAL) + status TaskEventStatus @default(UNSET) + + links Json? + events Json? + + /// This is the time the event started in nanoseconds since the epoch + startTime BigInt + + /// This is the duration of the event in nanoseconds + duration BigInt @default(0) + + attemptId String? + attemptNumber Int? + + environmentId String + environmentType RuntimeEnvironmentType + + organizationId String + + projectId String + projectRef String + + runId String + runIsTest Boolean @default(false) + + idempotencyKey String? + + taskSlug String + taskPath String? + taskExportName String? + + workerId String? + workerVersion String? + + queueId String? + queueName String? + + batchId String? + + /// This represents all the span attributes available, like http.status_code, and special attributes like $style.icon, $output, $metadata.payload.userId, as it's used for searching and filtering + properties Json + + /// This represents all span attributes in the $metadata namespace, like $metadata.payload + metadata Json? + + /// This represents all span attributes in the $style namespace, like $style + style Json? + + /// This represents all span attributes in the $output namespace, like $output + output Json? + + /// This represents the mimetype of the output, such as application/json or application/super+json + outputType String? + + payload Json? + payloadType String? + + createdAt DateTime @default(now()) + + // This represents the amount of "usage time" the event took, e.g. the CPU time + usageDurationMs Int @default(0) + usageCostInCents Float @default(0) + + machinePreset String? + machinePresetCpu Float? + machinePresetMemory Float? + machinePresetCentsPerMs Float? + + @@id([id, createdAt]) + /// Used on the run page + @@index([traceId]) + /// Used when looking up span events to complete when a run completes + @@index([spanId]) + // Used for getting all logs for a run + @@index([runId]) +} \ No newline at end of file diff --git a/tests/e2e/trigger.dev/trigger-dev.test.ts b/tests/e2e/trigger.dev/trigger-dev.test.ts new file mode 100644 index 00000000..6c5b9e32 --- /dev/null +++ b/tests/e2e/trigger.dev/trigger-dev.test.ts @@ -0,0 +1,12 @@ +import { generateTsSchema } from '@zenstackhq/testtools'; +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('Trigger.dev e2e tests', () => { + it('has a working schema', async () => { + await expect( + generateTsSchema(fs.readFileSync(path.join(__dirname, 'schema.zmodel'), 'utf8'), 'postgresql', 'cal-com'), + ).resolves.toBeTruthy(); + }); +}); diff --git a/tests/e2e/vitest.config.ts b/tests/e2e/vitest.config.ts new file mode 100644 index 00000000..04655403 --- /dev/null +++ b/tests/e2e/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import base from '../../vitest.base.config'; + +export default mergeConfig(base, defineConfig({}));