From bd22a34e158be429eec4ab606722a31e3234cb56 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 27 Nov 2025 15:09:29 +0100 Subject: [PATCH 01/37] poc: openworkflow --- packages/services/workflows/package.json | 17 ++ packages/services/workflows/src/context.ts | 7 + .../services/workflows/src/environment.ts | 3 + packages/services/workflows/src/index.ts | 34 ++++ packages/services/workflows/src/kit.ts | 71 ++++++++ .../workflows/src/lib/emails/components.ts | 52 ++++++ .../services/workflows/src/lib/emails/mjml.ts | 102 +++++++++++ .../workflows/src/lib/emails/providers.ts | 165 ++++++++++++++++++ .../lib/emails/templates/audit-logs-report.ts | 18 ++ .../emails/templates/email-verification.ts | 12 ++ .../templates/organization-invitation.ts | 11 ++ .../organization-ownership-transfer.ts | 18 ++ .../lib/emails/templates/password-reset.ts | 12 ++ .../emails/templates/rate-limit-exceeded.ts | 25 +++ .../emails/templates/rate-limit-warning.ts | 25 +++ .../src/workflows/audit-log-export.ts | 32 ++++ .../src/workflows/email-verification.ts | 27 +++ .../src/workflows/organization-invite.ts | 27 +++ .../organization-ownership-transfer.ts | 28 +++ .../workflows/src/workflows/password-reset.ts | 27 +++ .../workflows/schema-change-notification.ts | 40 +++++ .../workflows/usage-rate-limit-exceeded.ts | 34 ++++ .../src/workflows/usage-rate-limit-warning.ts | 34 ++++ packages/services/workflows/tsconfig.json | 9 + 24 files changed, 830 insertions(+) create mode 100644 packages/services/workflows/package.json create mode 100644 packages/services/workflows/src/context.ts create mode 100644 packages/services/workflows/src/environment.ts create mode 100644 packages/services/workflows/src/index.ts create mode 100644 packages/services/workflows/src/kit.ts create mode 100644 packages/services/workflows/src/lib/emails/components.ts create mode 100644 packages/services/workflows/src/lib/emails/mjml.ts create mode 100644 packages/services/workflows/src/lib/emails/providers.ts create mode 100644 packages/services/workflows/src/lib/emails/templates/audit-logs-report.ts create mode 100644 packages/services/workflows/src/lib/emails/templates/email-verification.ts create mode 100644 packages/services/workflows/src/lib/emails/templates/organization-invitation.ts create mode 100644 packages/services/workflows/src/lib/emails/templates/organization-ownership-transfer.ts create mode 100644 packages/services/workflows/src/lib/emails/templates/password-reset.ts create mode 100644 packages/services/workflows/src/lib/emails/templates/rate-limit-exceeded.ts create mode 100644 packages/services/workflows/src/lib/emails/templates/rate-limit-warning.ts create mode 100644 packages/services/workflows/src/workflows/audit-log-export.ts create mode 100644 packages/services/workflows/src/workflows/email-verification.ts create mode 100644 packages/services/workflows/src/workflows/organization-invite.ts create mode 100644 packages/services/workflows/src/workflows/organization-ownership-transfer.ts create mode 100644 packages/services/workflows/src/workflows/password-reset.ts create mode 100644 packages/services/workflows/src/workflows/schema-change-notification.ts create mode 100644 packages/services/workflows/src/workflows/usage-rate-limit-exceeded.ts create mode 100644 packages/services/workflows/src/workflows/usage-rate-limit-warning.ts create mode 100644 packages/services/workflows/tsconfig.json diff --git a/packages/services/workflows/package.json b/packages/services/workflows/package.json new file mode 100644 index 00000000000..87cab476d3b --- /dev/null +++ b/packages/services/workflows/package.json @@ -0,0 +1,17 @@ +{ + "name": "@hive/workflows", + "type": "module", + "license": "MIT", + "private": true, + "scripts": { + "build": "tsx ../../../scripts/runify.ts", + "dev": "tsup-node --config ../../../configs/tsup/dev.config.node.ts src/dev.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@graphql-hive/logger": "1.0.9", + "@openworkflow/backend-postgres": "0.3.0", + "openworkflow": "0.3.0", + "zod": "3.25.76" + } +} diff --git a/packages/services/workflows/src/context.ts b/packages/services/workflows/src/context.ts new file mode 100644 index 00000000000..e9358490ad1 --- /dev/null +++ b/packages/services/workflows/src/context.ts @@ -0,0 +1,7 @@ +import type { Logger } from '@graphql-hive/logger'; +import type { EmailProvider } from './lib/emails/providers'; + +export type Context = { + logger: Logger; + email: EmailProvider; +}; diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts new file mode 100644 index 00000000000..30c8f411e56 --- /dev/null +++ b/packages/services/workflows/src/environment.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const env = {}; diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts new file mode 100644 index 00000000000..a255b1821f2 --- /dev/null +++ b/packages/services/workflows/src/index.ts @@ -0,0 +1,34 @@ +import { OpenWorkflow } from 'openworkflow'; +import { BackendPostgres } from '@openworkflow/backend-postgres'; +import { Context } from './context.js'; + +const databaseUrl = 'postgresql://postgres:postgres@localhost:5432/postgres'; + +const backend = await BackendPostgres.connect(databaseUrl); +const ow = new OpenWorkflow({ backend }); + +const context: Context = { + email: {}, // TODO + logger: {}, // TODO +}; + +const modules = await Promise.all([ + import('./workflows/audit-log-export.js'), + import('./workflows/email-verification.js'), + import('./workflows/organization-invite.js'), + import('./workflows/organization-ownership-transfer.js'), + import('./workflows/password-reset.js'), + import('./workflows/schema-change-notification.js'), + import('./workflows/usage-rate-limit-exceeded.js'), + import('./workflows/usage-rate-limit-warning.js'), +]); + +for (const module of modules) { + module.register(ow, context); +} + +ow.newWorker({ + concurrency: 4, +}).start(); + +/////////// SCRATCH PAD diff --git a/packages/services/workflows/src/kit.ts b/packages/services/workflows/src/kit.ts new file mode 100644 index 00000000000..4f5b93d0dfe --- /dev/null +++ b/packages/services/workflows/src/kit.ts @@ -0,0 +1,71 @@ +import type { OpenWorkflow } from 'openworkflow'; +import type { + WorkflowDefinitionConfig as InternalWorkflowDefinitionConfig, + StepFunctionConfig, + WorkflowDefinition, + WorkflowRunHandle, +} from 'openworkflow/dist/client'; +import { DurationString } from 'openworkflow/dist/duration.js'; +import type { ZodType } from 'zod'; +import type { Context } from './context.js'; + +type WorkflowDefinitionConfig<$Schema = unknown> = InternalWorkflowDefinitionConfig & { + schema: ZodType<$Schema>; +}; + +export function declareWorkflow<$Schema = unknown>(args: WorkflowDefinitionConfig<$Schema>) { + return args; +} + +type StepFunction = () => Promise | Output | undefined; + +interface WorkflowFunctionParams { + input: Input; + step: StepApi; + version: string | null; +} + +interface StepApi { + run(config: StepFunctionConfig, fn: StepFunction): Promise; + sleep(name: string, duration: DurationString): Promise; +} + +// Task Logging Todos: unique task ID +// Inject logger instance with all necessary prefixes (step; etc.) + +/** + * Implement a workflow. + */ +export function workflow<$Schema = unknown>( + config: WorkflowDefinitionConfig<$Schema>, + implementation: ( + args: WorkflowFunctionParams<$Schema> & { context: Context }, + ) => Promise, +) { + return (ow: OpenWorkflow, context: Context) => + ow.defineWorkflow<$Schema, unknown>(config, args => { + return implementation({ ...args, context }); + }); +} + +async function noop() {} + +const scheduleWorkflowCache = new Map['run']>(); + +/** + * Schedule a workflow run from application code. + */ +export function scheduleWorkflow<$Schema>( + ow: OpenWorkflow, + config: WorkflowDefinitionConfig<$Schema>, + input: $Schema, +): Promise> { + let run = scheduleWorkflowCache.get(config.name); + if (!run) { + const definition = ow.defineWorkflow(config, noop); + run = input => definition.run(config.schema.parse(input)); + scheduleWorkflowCache.set(config.name, run); + } + + return run(input); +} diff --git a/packages/services/workflows/src/lib/emails/components.ts b/packages/services/workflows/src/lib/emails/components.ts new file mode 100644 index 00000000000..526c4ca4bf7 --- /dev/null +++ b/packages/services/workflows/src/lib/emails/components.ts @@ -0,0 +1,52 @@ +import { mjml, type MJMLValue } from './mjml.js'; + +export { mjml }; + +export function paragraph(content: string | MJMLValue) { + return mjml` + + ${content} + + `; +} + +export function button(input: { url: string; text: string }) { + return mjml` + + ${input.text} + + `; +} + +export function email(input: { title: string | MJMLValue; body: MJMLValue }) { + return mjml` + + + + + + Hive + + + + + + + + ${input.title} + + ${input.body} + + + + + + + © ${mjml.raw(String(new Date().getFullYear()))} Hive. All rights reserved. + + + + + + `.content; +} diff --git a/packages/services/workflows/src/lib/emails/mjml.ts b/packages/services/workflows/src/lib/emails/mjml.ts new file mode 100644 index 00000000000..87b789d1571 --- /dev/null +++ b/packages/services/workflows/src/lib/emails/mjml.ts @@ -0,0 +1,102 @@ +export type MJMLValue = { + readonly kind: 'mjml'; + readonly content: string; +}; + +type RawValue = { + readonly kind: 'raw'; + readonly content: string; +}; +type SpecialValues = RawValue; +type ValueExpression = string | SpecialValues | MJMLValue; + +export function mjml(parts: TemplateStringsArray, ...values: ValueExpression[]): MJMLValue { + let content = ''; + let index = 0; + + for (const part of parts) { + const token = values[index++]; + + content += part; + + if (index >= parts.length) { + continue; + } + + if (token === undefined) { + throw new Error('MJML tag cannot be bound an undefined value.'); + } else if (isRawValue(token)) { + content += token.content; + } else if (typeof token === 'string') { + content += escapeHtml(token); + } else if (token.kind === 'mjml') { + content += token.content; + } else { + throw new TypeError('mjml: Unexpected value expression.'); + } + } + + return { + kind: 'mjml', + content: content, + }; +} + +mjml.raw = (content: string): RawValue => ({ + kind: 'raw', + content, +}); + +/** + * @source https://github.com/component/escape-html + */ + +function escapeHtml(input: string): string { + const matchHtmlRegExp = /["'<>]/; + const match = matchHtmlRegExp.exec(input); + + if (!match) { + return input; + } + + let escapeSequence; + let html = ''; + let index = 0; + let lastIndex = 0; + + for (index = match.index; index < input.length; index++) { + switch (input.charCodeAt(index)) { + case 34: // " + escapeSequence = '"'; + break; + case 39: // ' + escapeSequence = '''; + break; + case 60: // < + escapeSequence = '<'; + break; + case 62: // > + escapeSequence = '>'; + break; + default: + continue; + } + + if (lastIndex !== index) { + html += input.substring(lastIndex, index); + } + + lastIndex = index + 1; + html += escapeSequence; + } + + return lastIndex !== index ? html + input.substring(lastIndex, index) : html; +} + +function isOfKind(value: unknown, kind: T['kind']): value is T { + return !!value && typeof value === 'object' && 'kind' in value && value.kind === kind; +} + +function isRawValue(value: unknown): value is RawValue { + return isOfKind(value, 'raw'); +} diff --git a/packages/services/workflows/src/lib/emails/providers.ts b/packages/services/workflows/src/lib/emails/providers.ts new file mode 100644 index 00000000000..35c968a206e --- /dev/null +++ b/packages/services/workflows/src/lib/emails/providers.ts @@ -0,0 +1,165 @@ +import nodemailer from 'nodemailer'; +import sm from 'sendmail'; + +interface Email { + to: string; + subject: string; + body: string; +} + +const emailProviders = { + postmark, + mock, + smtp, + sendmail, +}; + +export interface EmailProvider { + id: keyof typeof emailProviders; + send(email: Email): Promise; + history: Email[]; +} + +type PostmarkEmailProviderConfig = { + provider: 'postmark'; + token: string; + messageStream: string; +}; + +type MockEmailProviderConfig = { + provider: 'mock'; +}; + +type SMTPEmailProviderConfig = { + provider: 'smtp'; + protocol: 'smtp' | 'smtps'; + host: string; + port: number; + auth: { + user: string; + pass: string; + }; + tls: { + rejectUnauthorized: boolean; + }; +}; + +type SendmailEmailProviderConfig = { + provider: 'sendmail'; +}; + +type EmailProviderConfig = + | SMTPEmailProviderConfig + | PostmarkEmailProviderConfig + | SendmailEmailProviderConfig + | MockEmailProviderConfig; + +export function createEmailProvider(config: EmailProviderConfig, emailFrom: string): EmailProvider { + switch (config.provider) { + case 'mock': + return mock(config, emailFrom); + case 'postmark': + return postmark(config, emailFrom); + case 'smtp': + return smtp(config, emailFrom); + case 'sendmail': + return sendmail(config, emailFrom); + } +} + +function postmark(config: PostmarkEmailProviderConfig, emailFrom: string) { + return { + id: 'postmark' as const, + async send(email: Email) { + const response = await fetch('https://api.postmarkapp.com/email', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Postmark-Server-Token': config.token, + }, + body: JSON.stringify({ + From: emailFrom, + To: email.to, + Subject: email.subject, + HtmlBody: email.body, + MessageStream: config.messageStream, + }), + }); + + if (!response.ok) { + const details: any = await response.json(); + throw new Error(details.Message ?? response.statusText); + } + }, + history: [], + }; +} + +function mock(_config: MockEmailProviderConfig, _emailFrom: string): EmailProvider { + const history: Email[] = []; + + return { + id: 'mock' as const, + async send(email: Email) { + history.push(email); + }, + history, + }; +} + +function smtp(config: SMTPEmailProviderConfig, emailFrom: string) { + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.protocol === 'smtps', + auth: { + user: config.auth.user, + pass: config.auth.pass, + }, + tls: { + rejectUnauthorized: config.tls.rejectUnauthorized, + }, + }); + + return { + id: 'smtp' as const, + async send(email: Email) { + await transporter.sendMail({ + from: emailFrom, + to: email.to, + subject: email.subject, + html: email.body, + }); + }, + history: [], + }; +} + +function sendmail(_config: SendmailEmailProviderConfig, emailFrom: string) { + const client = sm({}); + + return { + id: 'sendmail' as const, + async send(email: Email) { + await new Promise((resolve, reject) => { + client( + { + from: emailFrom, + to: email.to, + subject: email.subject, + html: email.body, + }, + (err, reply) => { + if (err) { + reject(err); + } else { + resolve(reply); + } + }, + ); + }); + }, + history: [], + }; +} diff --git a/packages/services/workflows/src/lib/emails/templates/audit-logs-report.ts b/packages/services/workflows/src/lib/emails/templates/audit-logs-report.ts new file mode 100644 index 00000000000..10da6c8dda2 --- /dev/null +++ b/packages/services/workflows/src/lib/emails/templates/audit-logs-report.ts @@ -0,0 +1,18 @@ +import { button, email, mjml, paragraph } from '../components.js'; + +export function renderAuditLogsReportEmail(input: { + organizationName: string; + formattedStartDate: string; + formattedEndDate: string; + url: string; +}) { + return email({ + title: 'Your Requested Audit Logs Are Ready', + body: mjml` + ${paragraph(mjml`You requested audit logs for ${input.formattedStartDate} – ${input.formattedEndDate}, and they are now ready for download.`)} + ${paragraph('Click the link below to download your CSV file:')} + ${button({ url: input.url, text: 'Download Audit Logs' })} + ${paragraph(`If you didn't request this, please contact support@graphql-hive.com.`)} + `, + }); +} diff --git a/packages/services/workflows/src/lib/emails/templates/email-verification.ts b/packages/services/workflows/src/lib/emails/templates/email-verification.ts new file mode 100644 index 00000000000..e1936a6cfe3 --- /dev/null +++ b/packages/services/workflows/src/lib/emails/templates/email-verification.ts @@ -0,0 +1,12 @@ +import { button, email, mjml, paragraph } from '../components.js'; + +export function renderEmailVerificationEmail(input: { verificationLink: string; toEmail: string }) { + return email({ + title: `Verify Your Email Address`, + body: mjml` + ${paragraph(`To complete your sign-up, please verify your email address by clicking the link below:`)} + ${button({ url: input.verificationLink, text: 'Verify Email' })} + ${paragraph(`If you didn't sign up, you can ignore this email.`)} + `, + }); +} diff --git a/packages/services/workflows/src/lib/emails/templates/organization-invitation.ts b/packages/services/workflows/src/lib/emails/templates/organization-invitation.ts new file mode 100644 index 00000000000..4859b2d5d34 --- /dev/null +++ b/packages/services/workflows/src/lib/emails/templates/organization-invitation.ts @@ -0,0 +1,11 @@ +import { button, email, mjml, paragraph } from '../components.js'; + +export function renderOrganizationInvitation(input: { organizationName: string; link: string }) { + return email({ + title: `Join ${input.organizationName}`, + body: mjml` + ${paragraph(mjml`You've been invited to join ${input.organizationName} on GraphQL Hive.`)} + ${button({ url: input.link, text: 'Accept the invitation' })} + `, + }); +} diff --git a/packages/services/workflows/src/lib/emails/templates/organization-ownership-transfer.ts b/packages/services/workflows/src/lib/emails/templates/organization-ownership-transfer.ts new file mode 100644 index 00000000000..1bac1eaf43c --- /dev/null +++ b/packages/services/workflows/src/lib/emails/templates/organization-ownership-transfer.ts @@ -0,0 +1,18 @@ +import { button, email, mjml, paragraph } from '../components.js'; + +export function renderOrganizationOwnershipTransferEmail(input: { + authorName: string; + organizationName: string; + link: string; +}) { + return email({ + title: 'Organization Ownership Transfer Initiated', + body: mjml` + ${paragraph( + mjml`${input.authorName} wants to transfer the ownership of the ${input.organizationName} organization.`, + )} + ${button({ url: input.link, text: 'Accept the transfer' })} + ${paragraph(`This link will expire in a day.`)} + `, + }); +} diff --git a/packages/services/workflows/src/lib/emails/templates/password-reset.ts b/packages/services/workflows/src/lib/emails/templates/password-reset.ts new file mode 100644 index 00000000000..eec2491cc30 --- /dev/null +++ b/packages/services/workflows/src/lib/emails/templates/password-reset.ts @@ -0,0 +1,12 @@ +import { button, email, mjml, paragraph } from '../components.js'; + +export function renderPasswordResetEmail(input: { passwordResetLink: string; toEmail: string }) { + return email({ + title: `Reset Your Password`, + body: mjml` + ${paragraph(`We received a request to reset your password. Click the link below to set a new password:`)} + ${button({ url: input.passwordResetLink, text: 'Reset your password' })} + ${paragraph(`If you didn't request a password reset, you can ignore this email.`)} + `, + }); +} diff --git a/packages/services/workflows/src/lib/emails/templates/rate-limit-exceeded.ts b/packages/services/workflows/src/lib/emails/templates/rate-limit-exceeded.ts new file mode 100644 index 00000000000..298eb21bc39 --- /dev/null +++ b/packages/services/workflows/src/lib/emails/templates/rate-limit-exceeded.ts @@ -0,0 +1,25 @@ +import { button, email, mjml, paragraph } from '../components.js'; + +const numberFormatter = new Intl.NumberFormat(); + +export function renderRateLimitExceededEmail(input: { + organizationName: string; + limit: number; + currentUsage: number; + subscriptionManagementLink: string; +}) { + return email({ + title: 'Rate Limit Reached', + body: mjml` + ${paragraph( + mjml`Your Hive organization ${ + input.organizationName + } has reached over 100% of the operations limit quota.. Used ${numberFormatter.format(input.currentUsage)} of ${numberFormatter.format( + input.limit, + )}.`, + )} + ${paragraph(`We recommend to increase the limit.`)} + ${button({ url: input.subscriptionManagementLink, text: 'Manage your subscription' })} + `, + }); +} diff --git a/packages/services/workflows/src/lib/emails/templates/rate-limit-warning.ts b/packages/services/workflows/src/lib/emails/templates/rate-limit-warning.ts new file mode 100644 index 00000000000..cff0730ac4b --- /dev/null +++ b/packages/services/workflows/src/lib/emails/templates/rate-limit-warning.ts @@ -0,0 +1,25 @@ +import { button, email, mjml, paragraph } from '../components.js'; + +const numberFormatter = new Intl.NumberFormat(); + +export function renderRateLimitWarningEmail(input: { + organizationName: string; + limit: number; + currentUsage: number; + subscriptionManagementLink: string; +}) { + return email({ + title: 'Approaching Rate Limit', + body: mjml` + ${paragraph( + mjml`Your Hive organization ${ + input.organizationName + } is approaching its operations limit quota. Used ${numberFormatter.format(input.currentUsage)} of ${numberFormatter.format( + input.limit, + )}.`, + )} + ${paragraph(`We recommend to increase the limit.`)} + ${button({ url: input.subscriptionManagementLink, text: 'Manage your subscription' })} + `, + }); +} diff --git a/packages/services/workflows/src/workflows/audit-log-export.ts b/packages/services/workflows/src/workflows/audit-log-export.ts new file mode 100644 index 00000000000..ceb788fc66a --- /dev/null +++ b/packages/services/workflows/src/workflows/audit-log-export.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { declareWorkflow, workflow } from '../kit.js'; +import { renderAuditLogsReportEmail } from '../lib/emails/templates/audit-logs-report.js'; + +export const auditLogeExport = declareWorkflow({ + name: 'audit-log-export', + schema: z.object({ + organizationId: z.string(), + organizationName: z.string(), + formattedStartDate: z.string(), + formattedEndDate: z.string(), + url: z.string(), + email: z.string(), + }), +}); + +export const register = workflow(auditLogeExport, async args => { + // TODO: export audit log and store it + + await args.step.run({ name: 'send-email' }, async () => { + await args.context.email.send({ + to: args.input.email, + subject: 'Hive - Audit Log Report', + body: renderAuditLogsReportEmail({ + url: args.input.url, + organizationName: args.input.organizationName, + formattedStartDate: args.input.formattedStartDate, + formattedEndDate: args.input.formattedEndDate, + }), + }); + }); +}); diff --git a/packages/services/workflows/src/workflows/email-verification.ts b/packages/services/workflows/src/workflows/email-verification.ts new file mode 100644 index 00000000000..30ee24c741d --- /dev/null +++ b/packages/services/workflows/src/workflows/email-verification.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { declareWorkflow, workflow } from '../kit.js'; +import { renderEmailVerificationEmail } from '../lib/emails/templates/email-verification.js'; + +export const emailVerification = declareWorkflow({ + name: 'emailVerification', + schema: z.object({ + user: z.object({ + email: z.string(), + id: z.string(), + }), + emailVerifyLink: z.string(), + }), +}); + +export const register = workflow(emailVerification, async args => { + await args.step.run({ name: 'send-email' }, async () => { + await args.context.email.send({ + to: args.input.user.email, + subject: 'Verify your email', + body: renderEmailVerificationEmail({ + verificationLink: args.input.emailVerifyLink, + toEmail: args.input.user.email, + }), + }); + }); +}); diff --git a/packages/services/workflows/src/workflows/organization-invite.ts b/packages/services/workflows/src/workflows/organization-invite.ts new file mode 100644 index 00000000000..24eccf33fd2 --- /dev/null +++ b/packages/services/workflows/src/workflows/organization-invite.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { declareWorkflow, workflow } from '../kit.js'; +import { renderOrganizationInvitation } from '../lib/emails/templates/organization-invitation.js'; + +export const organizationInvitation = declareWorkflow({ + name: 'organizationInvitation', + schema: z.object({ + organizationId: z.string(), + organizationName: z.string(), + code: z.string(), + email: z.string(), + link: z.string(), + }), +}); + +export const register = workflow(organizationInvitation, async args => { + await args.step.run({ name: 'send-email' }, async () => { + await args.context.email.send({ + to: args.input.email, + subject: `You have been invited to join ${args.input.organizationName}`, + body: renderOrganizationInvitation({ + link: args.input.link, + organizationName: args.input.organizationName, + }), + }); + }); +}); diff --git a/packages/services/workflows/src/workflows/organization-ownership-transfer.ts b/packages/services/workflows/src/workflows/organization-ownership-transfer.ts new file mode 100644 index 00000000000..c0fad91ebcc --- /dev/null +++ b/packages/services/workflows/src/workflows/organization-ownership-transfer.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +import { declareWorkflow, workflow } from '../kit.js'; +import { renderOrganizationOwnershipTransferEmail } from '../lib/emails/templates/organization-ownership-transfer.js'; + +export const organizationOwnershipTransfer = declareWorkflow({ + name: 'organizationOwnershipTransfer', + schema: z.object({ + organizationId: z.string(), + organizationName: z.string(), + authorName: z.string(), + email: z.string(), + link: z.string(), + }), +}); + +export const register = workflow(organizationOwnershipTransfer, async args => { + await args.step.run({ name: 'send-email' }, async () => { + await args.context.email.send({ + to: args.input.email, + subject: `Organization transfer from ${args.input.authorName} (${args.input.organizationName})`, + body: renderOrganizationOwnershipTransferEmail({ + link: args.input.link, + organizationName: args.input.organizationName, + authorName: args.input.authorName, + }), + }); + }); +}); diff --git a/packages/services/workflows/src/workflows/password-reset.ts b/packages/services/workflows/src/workflows/password-reset.ts new file mode 100644 index 00000000000..7d468ce3773 --- /dev/null +++ b/packages/services/workflows/src/workflows/password-reset.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { declareWorkflow, workflow } from '../kit'; +import { renderPasswordResetEmail } from '../lib/emails/templates/password-reset'; + +export const passwordReset = declareWorkflow({ + name: 'passwordReset', + schema: z.object({ + user: z.object({ + email: z.string(), + id: z.string(), + }), + passwordResetLink: z.string(), + }), +}); + +export const register = workflow(passwordReset, async args => { + await args.step.run({ name: 'send-email' }, async () => { + await args.context.email.send({ + subject: `Reset your password`, + to: args.input.user.email, + body: renderPasswordResetEmail({ + passwordResetLink: args.input.passwordResetLink, + toEmail: args.input.user.email, + }), + }); + }); +}); diff --git a/packages/services/workflows/src/workflows/schema-change-notification.ts b/packages/services/workflows/src/workflows/schema-change-notification.ts new file mode 100644 index 00000000000..40c0a017dd9 --- /dev/null +++ b/packages/services/workflows/src/workflows/schema-change-notification.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { declareWorkflow, workflow } from '../kit'; + +export const schemaChangeNotification = declareWorkflow({ + name: 'schemaChangeNotification', + schema: z.object({ + endpoint: z.string().nonempty(), + event: z.object({ + organization: z.object({ + id: z.string().nonempty(), + cleanId: z.string().nonempty(), + slug: z.string().nonempty(), + name: z.string().nonempty(), + }), + project: z.object({ + id: z.string().nonempty(), + cleanId: z.string().nonempty(), + slug: z.string().nonempty(), + name: z.string().nonempty(), + }), + target: z.object({ + id: z.string().nonempty(), + cleanId: z.string().nonempty(), + slug: z.string().nonempty(), + name: z.string().nonempty(), + }), + schema: z.object({ + id: z.string().nonempty(), + valid: z.boolean(), + commit: z.string().nonempty(), + }), + changes: z.array(z.any()), + errors: z.array(z.any()), + }), + }), +}); + +export const register = workflow(schemaChangeNotification, async args => { + await args.step.run({ name: 'send-webhook' }, async () => {}); +}); diff --git a/packages/services/workflows/src/workflows/usage-rate-limit-exceeded.ts b/packages/services/workflows/src/workflows/usage-rate-limit-exceeded.ts new file mode 100644 index 00000000000..2dffa486cb3 --- /dev/null +++ b/packages/services/workflows/src/workflows/usage-rate-limit-exceeded.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { declareWorkflow, workflow } from '../kit'; +import { renderRateLimitExceededEmail } from '../lib/emails/templates/rate-limit-exceeded'; + +export const usageRateLimitExceeded = declareWorkflow({ + name: 'usageRateLimitExceeded', + schema: z.object({ + organizationId: z.string(), + organizationName: z.string(), + limit: z.number(), + currentUsage: z.number(), + startDate: z.number(), + endDate: z.number(), + subscriptionManagementLink: z.string(), + email: z.string(), + }), +}); + +export const register = workflow(usageRateLimitExceeded, async args => { + await args.step.run({ name: 'send-email' }, async () => { + await args.context.email.send({ + subject: `GraphQL-Hive operations quota for ${args.input.organizationName} exceeded`, + to: args.input.email, + body: renderRateLimitExceededEmail({ + organizationName: args.input.organizationName, + currentUsage: args.input.currentUsage, + limit: args.input.limit, + subscriptionManagementLink: args.input.subscriptionManagementLink, + }), + }); + }); + + // TODO: Webhooks ?! +}); diff --git a/packages/services/workflows/src/workflows/usage-rate-limit-warning.ts b/packages/services/workflows/src/workflows/usage-rate-limit-warning.ts new file mode 100644 index 00000000000..638481d3e82 --- /dev/null +++ b/packages/services/workflows/src/workflows/usage-rate-limit-warning.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { declareWorkflow, workflow } from '../kit'; +import { renderRateLimitWarningEmail } from '../lib/emails/templates/rate-limit-warning'; + +export const usageRateLimitWarning = declareWorkflow({ + name: 'usageRateLimitWarning', + schema: z.object({ + organizationId: z.string(), + organizationName: z.string(), + limit: z.number(), + currentUsage: z.number(), + startDate: z.number(), + endDate: z.number(), + subscriptionManagementLink: z.string(), + email: z.string(), + }), +}); + +export const register = workflow(usageRateLimitWarning, async args => { + await args.step.run({ name: 'send-email' }, async () => { + await args.context.email.send({ + subject: `GraphQL-Hive operations quota for ${args.input.organizationName} exceeded`, + to: args.input.email, + body: renderRateLimitWarningEmail({ + organizationName: args.input.organizationName, + limit: args.input.limit, + currentUsage: args.input.currentUsage, + subscriptionManagementLink: args.input.subscriptionManagementLink, + }), + }); + }); + + // TODO: Webhooks ?! +}); diff --git a/packages/services/workflows/tsconfig.json b/packages/services/workflows/tsconfig.json new file mode 100644 index 00000000000..60753012edb --- /dev/null +++ b/packages/services/workflows/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "esnext", + "rootDir": "../.." + }, + "files": ["src/index.ts"] +} From 223ae46ce34b0db866f7914c2963c950c7211b0e Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 28 Nov 2025 14:26:40 +0100 Subject: [PATCH 02/37] wip --- .zed/settings.json | 22 ++ packages/services/workflows/package.json | 1 + packages/services/workflows/src/context.ts | 2 + packages/services/workflows/src/index.ts | 74 ++-- .../src/lib/expired-schema-checks.ts | 181 ++++++++++ packages/services/workflows/src/lol.ts | 7 + .../workflows/src/postgraphile-kit.ts | 55 +++ .../workflows/src/tasks/audit-log-export.ts | 29 ++ .../workflows/src/tasks/email-verification.ts | 25 ++ .../src/tasks/organization-invite.ts | 25 ++ .../tasks/organization-ownership-transfer.ts | 26 ++ .../workflows/src/tasks/password-reset.ts | 25 ++ .../src/tasks/purge-expired-schema-checks.ts | 12 + .../schema-change-notification.ts | 8 +- .../src/tasks/usage-rate-limit-exceeded.ts | 30 ++ .../src/tasks/usage-rate-limit-warning.ts | 30 ++ packages/services/workflows/src/workflows.ts | 332 ++++++++++++++++++ .../src/workflows/audit-log-export.ts | 32 -- .../src/workflows/email-verification.ts | 27 -- .../src/workflows/organization-invite.ts | 27 -- .../organization-ownership-transfer.ts | 28 -- .../workflows/src/workflows/password-reset.ts | 27 -- .../workflows/usage-rate-limit-exceeded.ts | 34 -- .../src/workflows/usage-rate-limit-warning.ts | 34 -- .../src/workflows/user-onboarding.ts | 62 ++++ pnpm-lock.yaml | 296 +++++++++++----- 26 files changed, 1123 insertions(+), 328 deletions(-) create mode 100644 .zed/settings.json create mode 100644 packages/services/workflows/src/lib/expired-schema-checks.ts create mode 100644 packages/services/workflows/src/lol.ts create mode 100644 packages/services/workflows/src/postgraphile-kit.ts create mode 100644 packages/services/workflows/src/tasks/audit-log-export.ts create mode 100644 packages/services/workflows/src/tasks/email-verification.ts create mode 100644 packages/services/workflows/src/tasks/organization-invite.ts create mode 100644 packages/services/workflows/src/tasks/organization-ownership-transfer.ts create mode 100644 packages/services/workflows/src/tasks/password-reset.ts create mode 100644 packages/services/workflows/src/tasks/purge-expired-schema-checks.ts rename packages/services/workflows/src/{workflows => tasks}/schema-change-notification.ts (78%) create mode 100644 packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts create mode 100644 packages/services/workflows/src/tasks/usage-rate-limit-warning.ts create mode 100644 packages/services/workflows/src/workflows.ts delete mode 100644 packages/services/workflows/src/workflows/audit-log-export.ts delete mode 100644 packages/services/workflows/src/workflows/email-verification.ts delete mode 100644 packages/services/workflows/src/workflows/organization-invite.ts delete mode 100644 packages/services/workflows/src/workflows/organization-ownership-transfer.ts delete mode 100644 packages/services/workflows/src/workflows/password-reset.ts delete mode 100644 packages/services/workflows/src/workflows/usage-rate-limit-exceeded.ts delete mode 100644 packages/services/workflows/src/workflows/usage-rate-limit-warning.ts create mode 100644 packages/services/workflows/src/workflows/user-onboarding.ts diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 00000000000..a7b2dd384ad --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,22 @@ +{ + "languages": { + "Markdown": { + "format_on_save": "on", + "formatter": { + "external": { + "command": "./node_modules/.bin/prettier", + "arguments": ["--stdin-filepath", "{buffer_path}", "--ignore-unknown"] + } + } + }, + "JavaScript": { + "format_on_save": "on", + "formatter": { + "external": { + "command": "./node_modules/.bin/prettier", + "arguments": ["--stdin-filepath", "{buffer_path}", "--ignore-unknown"] + } + } + } + } +} diff --git a/packages/services/workflows/package.json b/packages/services/workflows/package.json index 87cab476d3b..c02889fdbee 100644 --- a/packages/services/workflows/package.json +++ b/packages/services/workflows/package.json @@ -11,6 +11,7 @@ "dependencies": { "@graphql-hive/logger": "1.0.9", "@openworkflow/backend-postgres": "0.3.0", + "graphile-worker": "0.16.6", "openworkflow": "0.3.0", "zod": "3.25.76" } diff --git a/packages/services/workflows/src/context.ts b/packages/services/workflows/src/context.ts index e9358490ad1..ceed0ed8e3c 100644 --- a/packages/services/workflows/src/context.ts +++ b/packages/services/workflows/src/context.ts @@ -1,7 +1,9 @@ +import type { DatabasePool } from 'slonik'; import type { Logger } from '@graphql-hive/logger'; import type { EmailProvider } from './lib/emails/providers'; export type Context = { logger: Logger; email: EmailProvider; + pg: DatabasePool; }; diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index a255b1821f2..9357983e6d0 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -1,34 +1,58 @@ -import { OpenWorkflow } from 'openworkflow'; -import { BackendPostgres } from '@openworkflow/backend-postgres'; +import { + Logger as GraphileLogger, + LogLevel as GraphileLogLevel, + run, + Runner, +} from 'graphile-worker'; +import { createPool } from 'slonik'; +import { Logger } from '@graphql-hive/logger'; import { Context } from './context.js'; -const databaseUrl = 'postgresql://postgres:postgres@localhost:5432/postgres'; +// TODO: slonik interop +// +const databaseUrl = 'postgresql://postgres:postgres@localhost:5432/registry'; -const backend = await BackendPostgres.connect(databaseUrl); -const ow = new OpenWorkflow({ backend }); - -const context: Context = { - email: {}, // TODO - logger: {}, // TODO -}; +const pool = await createPool(databaseUrl); const modules = await Promise.all([ - import('./workflows/audit-log-export.js'), - import('./workflows/email-verification.js'), - import('./workflows/organization-invite.js'), - import('./workflows/organization-ownership-transfer.js'), - import('./workflows/password-reset.js'), - import('./workflows/schema-change-notification.js'), - import('./workflows/usage-rate-limit-exceeded.js'), - import('./workflows/usage-rate-limit-warning.js'), + import('./tasks/audit-log-export.js'), + import('./tasks/email-verification.js'), + import('./tasks/organization-invite.js'), + import('./tasks/organization-ownership-transfer.js'), + import('./tasks/password-reset.js'), + import('./tasks/schema-change-notification.js'), + import('./tasks/usage-rate-limit-exceeded.js'), + import('./tasks/usage-rate-limit-warning.js'), + import('./workflows/user-onboarding.js'), ]); -for (const module of modules) { - module.register(ow, context); -} +const logger = new Logger({ level: 'debug' }); -ow.newWorker({ - concurrency: 4, -}).start(); +const context: Context = { logger, email: {} }; + +function logLevel(level: GraphileLogLevel) { + switch (level) { + case 'warning': + return 'warn' as const; + case 'info': { + return 'info' as const; + } + case 'debug': { + return 'debug' as const; + } + case 'error': { + return 'error' as const; + } + } + throw new Error('nooop'); +} -/////////// SCRATCH PAD +let runner: Runner = await run({ + logger: new GraphileLogger(scope => (level, message, meta) => { + logger[logLevel(level)](message); + }), + crontab: ' ', + connectionString: databaseUrl, + taskList: Object.fromEntries(modules.map(module => module.task(context))), + +}); diff --git a/packages/services/workflows/src/lib/expired-schema-checks.ts b/packages/services/workflows/src/lib/expired-schema-checks.ts new file mode 100644 index 00000000000..298c06d3657 --- /dev/null +++ b/packages/services/workflows/src/lib/expired-schema-checks.ts @@ -0,0 +1,181 @@ +import { DatabasePool, sql } from 'slonik'; +import { z } from 'zod'; + +export async function purgeExpiredSchemaChecks(args: { pool: DatabasePool; expiresAt: Date }) { + const SchemaCheckModel = z.object({ + schemaCheckIds: z.array(z.string()), + sdlStoreIds: z.array(z.string()), + contextIds: z.array(z.string()), + targetIds: z.array(z.string()), + contractIds: z.array(z.string()), + }); + + return await args.pool.transaction(async pool => { + const date = args.expiresAt.toISOString(); + const rawData = await pool.maybeOne(sql`/* findSchemaChecksToPurge */ + WITH "filtered_schema_checks" AS ( + SELECT * + FROM "schema_checks" + WHERE "expires_at" <= ${date} + ) + SELECT + ARRAY(SELECT "filtered_schema_checks"."id" FROM "filtered_schema_checks") AS "schemaCheckIds", + ARRAY(SELECT DISTINCT "filtered_schema_checks"."target_id" FROM "filtered_schema_checks") AS "targetIds", + ARRAY( + SELECT DISTINCT "filtered_schema_checks"."schema_sdl_store_id" + FROM "filtered_schema_checks" + WHERE "filtered_schema_checks"."schema_sdl_store_id" IS NOT NULL + + UNION SELECT DISTINCT "filtered_schema_checks"."composite_schema_sdl_store_id" + FROM "filtered_schema_checks" + WHERE "filtered_schema_checks"."composite_schema_sdl_store_id" IS NOT NULL + + UNION SELECT DISTINCT "filtered_schema_checks"."supergraph_sdl_store_id" + FROM "filtered_schema_checks" + WHERE "filtered_schema_checks"."supergraph_sdl_store_id" IS NOT NULL + + UNION SELECT DISTINCT "contract_checks"."composite_schema_sdl_store_id" + FROM "contract_checks" + INNER JOIN "filtered_schema_checks" ON "contract_checks"."schema_check_id" = "filtered_schema_checks"."id" + WHERE "contract_checks"."composite_schema_sdl_store_id" IS NOT NULL + + UNION SELECT DISTINCT "contract_checks"."supergraph_sdl_store_id" FROM "filtered_schema_checks" + INNER JOIN "contract_checks" ON "contract_checks"."schema_check_id" = "filtered_schema_checks"."id" + WHERE "contract_checks"."supergraph_sdl_store_id" IS NOT NULL + ) AS "sdlStoreIds", + ARRAY( + SELECT DISTINCT "filtered_schema_checks"."context_id" + FROM "filtered_schema_checks" + WHERE "filtered_schema_checks"."context_id" IS NOT NULL + ) AS "contextIds", + ARRAY( + SELECT DISTINCT "contract_checks"."contract_id" + FROM "contract_checks" + INNER JOIN "filtered_schema_checks" ON "contract_checks"."schema_check_id" = "filtered_schema_checks"."id" + ) AS "contractIds" + `); + + const data = SchemaCheckModel.parse(rawData); + + if (!data.schemaCheckIds.length) { + return { + deletedSchemaCheckCount: 0, + deletedSdlStoreCount: 0, + deletedSchemaChangeApprovalCount: 0, + deletedContractSchemaChangeApprovalCount: 0, + }; + } + + let deletedSdlStoreCount = 0; + let deletedSchemaChangeApprovalCount = 0; + let deletedContractSchemaChangeApprovalCount = 0; + + await pool.any(sql`/* purgeExpiredSchemaChecks */ + DELETE + FROM "schema_checks" + WHERE + "id" = ANY(${sql.array(data.schemaCheckIds, 'uuid')}) + `); + + if (data.sdlStoreIds.length) { + deletedSdlStoreCount = await pool.oneFirst(sql`/* purgeExpiredSdlStore */ + WITH "deleted" AS ( + DELETE + FROM + "sdl_store" + WHERE + "id" = ANY( + ${sql.array(data.sdlStoreIds, 'text')} + ) + AND NOT EXISTS ( + SELECT + 1 + FROM + "schema_checks" + WHERE + "schema_checks"."schema_sdl_store_id" = "sdl_store"."id" + OR "schema_checks"."composite_schema_sdl_store_id" = "sdl_store"."id" + OR "schema_checks"."supergraph_sdl_store_id" = "sdl_store"."id" + ) + AND NOT EXISTS ( + SELECT + 1 + FROM + "contract_checks" + WHERE + "contract_checks"."composite_schema_sdl_store_id" = "sdl_store"."id" + OR "contract_checks"."supergraph_sdl_store_id" = "sdl_store"."id" + ) + RETURNING + "id" + ) SELECT COUNT(*) FROM "deleted" + `); + } + + if (data.targetIds.length && data.contextIds.length) { + deletedSchemaChangeApprovalCount = + await pool.oneFirst(sql`/* purgeExpiredSchemaChangeApprovals */ + WITH "deleted" AS ( + DELETE + FROM + "schema_change_approvals" + WHERE + "target_id" = ANY( + ${sql.array(data.targetIds, 'uuid')} + ) + AND "context_id" = ANY( + ${sql.array(data.contextIds, 'text')} + ) + AND NOT EXISTS ( + SELECT + 1 + FROM "schema_checks" + WHERE + "schema_checks"."target_id" = "schema_change_approvals"."target_id" + AND "schema_checks"."context_id" = "schema_change_approvals"."context_id" + ) + RETURNING + "target_id" + ) SELECT COUNT(*) FROM "deleted" + `); + } + + if (data.contractIds.length && data.contextIds.length) { + deletedContractSchemaChangeApprovalCount = + await pool.oneFirst(sql`/* purgeExpiredContractSchemaChangeApprovals */ + WITH "deleted" AS ( + DELETE + FROM + "contract_schema_change_approvals" + WHERE + "contract_id" = ANY( + ${sql.array(data.contractIds, 'uuid')} + ) + AND "context_id" = ANY( + ${sql.array(data.contextIds, 'text')} + ) + AND NOT EXISTS ( + SELECT + 1 + FROM + "schema_checks" + INNER JOIN "contract_checks" + ON "contract_checks"."schema_check_id" = "schema_checks"."id" + WHERE + "contract_checks"."contract_id" = "contract_schema_change_approvals"."contract_id" + AND "schema_checks"."context_id" = "contract_schema_change_approvals"."context_id" + ) + RETURNING + "contract_id" + ) SELECT COUNT(*) FROM "deleted" + `); + } + + return { + deletedSchemaCheckCount: data.schemaCheckIds.length, + deletedSdlStoreCount, + deletedSchemaChangeApprovalCount, + deletedContractSchemaChangeApprovalCount, + }; + }); +} diff --git a/packages/services/workflows/src/lol.ts b/packages/services/workflows/src/lol.ts new file mode 100644 index 00000000000..c1a1e957944 --- /dev/null +++ b/packages/services/workflows/src/lol.ts @@ -0,0 +1,7 @@ +import { queueWorkflow } from './workflows.js'; +import { UserOnboardingWorkflow } from './workflows/user-onboarding.js'; + +queueWorkflow(UserOnboardingWorkflow, { + organizationId: 'abc', + userId: 'xyz', +}); diff --git a/packages/services/workflows/src/postgraphile-kit.ts b/packages/services/workflows/src/postgraphile-kit.ts new file mode 100644 index 00000000000..7f66920a7f7 --- /dev/null +++ b/packages/services/workflows/src/postgraphile-kit.ts @@ -0,0 +1,55 @@ +import { JobHelpers, Task } from 'graphile-worker'; +import { z } from 'zod'; +import { Logger } from '@graphql-hive/logger'; +import { Context } from './context'; + +export type TaskDefinition = { + name: TName; + schema: z.ZodTypeAny & { _output: TModel }; +}; + +export function defineTask( + workflow: TaskDefinition, +): TaskDefinition { + return workflow; +} + +type TaskImplementationArgs = { + input: TPayload; + context: Context; + logger: Logger; + helpers: JobHelpers; +}; + +export type TaskImplementation = ( + args: TaskImplementationArgs, +) => Promise; + +export function implementTask( + taskDefinition: TaskDefinition, + implementation: TaskImplementation, +): (context: Context) => [string, Task] { + return function (context) { + return [ + taskDefinition.name, + function (unsafePayload, helpers) { + const input = taskDefinition.schema.parse(unsafePayload); + return implementation({ + input, + context, + helpers, + logger: context.logger.child({ + attrs: { + 'job.id': helpers.job.id, + 'job.queueId': helpers.job.job_queue_id, + 'job.attempts': helpers.job.attempts, + 'job.maxAttempts': helpers.job.max_attempts, + 'job.priority': helpers.job.priority, + 'job.taskId': helpers.job.task_id, + }, + }), + }); + }, + ]; + }; +} diff --git a/packages/services/workflows/src/tasks/audit-log-export.ts b/packages/services/workflows/src/tasks/audit-log-export.ts new file mode 100644 index 00000000000..0d51455b35a --- /dev/null +++ b/packages/services/workflows/src/tasks/audit-log-export.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { renderAuditLogsReportEmail } from '../lib/emails/templates/audit-logs-report.js'; +import { defineTask, implementTask } from '../postgraphile-kit.js'; + +export const AuditLogExportTask = defineTask({ + name: 'audit-log-export', + schema: z.object({ + organizationId: z.string(), + organizationName: z.string(), + formattedStartDate: z.string(), + formattedEndDate: z.string(), + url: z.string(), + email: z.string(), + }), +}); + +export const task = implementTask(AuditLogExportTask, async args => { + // TODO: export audit log and store it + await args.context.email.send({ + to: args.input.email, + subject: 'Hive - Audit Log Report', + body: renderAuditLogsReportEmail({ + url: args.input.url, + organizationName: args.input.organizationName, + formattedStartDate: args.input.formattedStartDate, + formattedEndDate: args.input.formattedEndDate, + }), + }); +}); diff --git a/packages/services/workflows/src/tasks/email-verification.ts b/packages/services/workflows/src/tasks/email-verification.ts new file mode 100644 index 00000000000..25271faf9ed --- /dev/null +++ b/packages/services/workflows/src/tasks/email-verification.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { renderEmailVerificationEmail } from '../lib/emails/templates/email-verification.js'; +import { defineTask, implementTask } from '../postgraphile-kit.js'; + +export const EmailVerificationTask = defineTask({ + name: 'emailVerification', + schema: z.object({ + user: z.object({ + email: z.string(), + id: z.string(), + }), + emailVerifyLink: z.string(), + }), +}); + +export const task = implementTask(EmailVerificationTask, async args => { + await args.context.email.send({ + to: args.input.user.email, + subject: 'Verify your email', + body: renderEmailVerificationEmail({ + verificationLink: args.input.emailVerifyLink, + toEmail: args.input.user.email, + }), + }); +}); diff --git a/packages/services/workflows/src/tasks/organization-invite.ts b/packages/services/workflows/src/tasks/organization-invite.ts new file mode 100644 index 00000000000..2a69253b0e6 --- /dev/null +++ b/packages/services/workflows/src/tasks/organization-invite.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { renderOrganizationInvitation } from '../lib/emails/templates/organization-invitation.js'; +import { defineTask, implementTask } from '../postgraphile-kit.js'; + +export const OrganizationInvitationTask = defineTask({ + name: 'organizationInvitation', + schema: z.object({ + organizationId: z.string(), + organizationName: z.string(), + code: z.string(), + email: z.string(), + link: z.string(), + }), +}); + +export const task = implementTask(OrganizationInvitationTask, async args => { + await args.context.email.send({ + to: args.input.email, + subject: `You have been invited to join ${args.input.organizationName}`, + body: renderOrganizationInvitation({ + link: args.input.link, + organizationName: args.input.organizationName, + }), + }); +}); diff --git a/packages/services/workflows/src/tasks/organization-ownership-transfer.ts b/packages/services/workflows/src/tasks/organization-ownership-transfer.ts new file mode 100644 index 00000000000..c44ce19ca07 --- /dev/null +++ b/packages/services/workflows/src/tasks/organization-ownership-transfer.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { renderOrganizationOwnershipTransferEmail } from '../lib/emails/templates/organization-ownership-transfer.js'; +import { defineTask, implementTask } from '../postgraphile-kit.js'; + +export const OrganizationOwnershipTransferTask = defineTask({ + name: 'organizationOwnershipTransfer', + schema: z.object({ + organizationId: z.string(), + organizationName: z.string(), + authorName: z.string(), + email: z.string(), + link: z.string(), + }), +}); + +export const task = implementTask(OrganizationOwnershipTransferTask, async args => { + await args.context.email.send({ + to: args.input.email, + subject: `Organization transfer from ${args.input.authorName} (${args.input.organizationName})`, + body: renderOrganizationOwnershipTransferEmail({ + link: args.input.link, + organizationName: args.input.organizationName, + authorName: args.input.authorName, + }), + }); +}); diff --git a/packages/services/workflows/src/tasks/password-reset.ts b/packages/services/workflows/src/tasks/password-reset.ts new file mode 100644 index 00000000000..a02696ee01d --- /dev/null +++ b/packages/services/workflows/src/tasks/password-reset.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { renderPasswordResetEmail } from '../lib/emails/templates/password-reset'; +import { defineTask, implementTask } from '../postgraphile-kit.js'; + +export const PasswordResetTask = defineTask({ + name: 'passwordReset', + schema: z.object({ + user: z.object({ + email: z.string(), + id: z.string(), + }), + passwordResetLink: z.string(), + }), +}); + +export const task = implementTask(PasswordResetTask, async args => { + await args.context.email.send({ + subject: `Reset your password`, + to: args.input.user.email, + body: renderPasswordResetEmail({ + passwordResetLink: args.input.passwordResetLink, + toEmail: args.input.user.email, + }), + }); +}); diff --git a/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts b/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts new file mode 100644 index 00000000000..0dcb0a04378 --- /dev/null +++ b/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { purgeExpiredSchemaChecks } from '../lib/expired-schema-checks'; +import { defineTask, implementTask } from '../postgraphile-kit.js'; + +export const PurgeExpiredSchemaChecks = defineTask({ + name: 'purgeExpiredSchemaChecks', + schema: z.undefined(), +}); + +export const task = implementTask(PurgeExpiredSchemaChecks, async args => { + await purgeExpiredSchemaChecks({ pool: args.context.pg, expiresAt: new Date() }); +}); diff --git a/packages/services/workflows/src/workflows/schema-change-notification.ts b/packages/services/workflows/src/tasks/schema-change-notification.ts similarity index 78% rename from packages/services/workflows/src/workflows/schema-change-notification.ts rename to packages/services/workflows/src/tasks/schema-change-notification.ts index 40c0a017dd9..be9cb018abc 100644 --- a/packages/services/workflows/src/workflows/schema-change-notification.ts +++ b/packages/services/workflows/src/tasks/schema-change-notification.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { declareWorkflow, workflow } from '../kit'; +import { defineTask, implementTask } from '../postgraphile-kit.js'; -export const schemaChangeNotification = declareWorkflow({ +export const SchemaChangeNotificationTask = defineTask({ name: 'schemaChangeNotification', schema: z.object({ endpoint: z.string().nonempty(), @@ -35,6 +35,4 @@ export const schemaChangeNotification = declareWorkflow({ }), }); -export const register = workflow(schemaChangeNotification, async args => { - await args.step.run({ name: 'send-webhook' }, async () => {}); -}); +export const task = implementTask(SchemaChangeNotificationTask, async args => {}); diff --git a/packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts b/packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts new file mode 100644 index 00000000000..565d549f973 --- /dev/null +++ b/packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { renderRateLimitExceededEmail } from '../lib/emails/templates/rate-limit-exceeded'; +import { defineTask, implementTask } from '../postgraphile-kit.js'; + +export const UsageRateLimitExceededTask = defineTask({ + name: 'usageRateLimitExceeded', + schema: z.object({ + organizationId: z.string(), + organizationName: z.string(), + limit: z.number(), + currentUsage: z.number(), + startDate: z.number(), + endDate: z.number(), + subscriptionManagementLink: z.string(), + email: z.string(), + }), +}); + +export const task = implementTask(UsageRateLimitExceededTask, async args => { + await args.context.email.send({ + subject: `GraphQL-Hive operations quota for ${args.input.organizationName} exceeded`, + to: args.input.email, + body: renderRateLimitExceededEmail({ + organizationName: args.input.organizationName, + currentUsage: args.input.currentUsage, + limit: args.input.limit, + subscriptionManagementLink: args.input.subscriptionManagementLink, + }), + }); +}); diff --git a/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts b/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts new file mode 100644 index 00000000000..51a4b783fd6 --- /dev/null +++ b/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { renderRateLimitWarningEmail } from '../lib/emails/templates/rate-limit-warning'; +import { defineTask, implementTask } from '../postgraphile-kit.js'; + +export const UsageRateLimitWarningTask = defineTask({ + name: 'usageRateLimitWarning', + schema: z.object({ + organizationId: z.string(), + organizationName: z.string(), + limit: z.number(), + currentUsage: z.number(), + startDate: z.number(), + endDate: z.number(), + subscriptionManagementLink: z.string(), + email: z.string(), + }), +}); + +export const task = implementTask(UsageRateLimitWarningTask, async args => { + await args.context.email.send({ + subject: `GraphQL-Hive operations quota for ${args.input.organizationName} exceeded`, + to: args.input.email, + body: renderRateLimitWarningEmail({ + organizationName: args.input.organizationName, + limit: args.input.limit, + currentUsage: args.input.currentUsage, + subscriptionManagementLink: args.input.subscriptionManagementLink, + }), + }); +}); diff --git a/packages/services/workflows/src/workflows.ts b/packages/services/workflows/src/workflows.ts new file mode 100644 index 00000000000..7a6375a15b2 --- /dev/null +++ b/packages/services/workflows/src/workflows.ts @@ -0,0 +1,332 @@ +import { JobHelpers, quickAddJob, Task } from 'graphile-worker'; +import { Client as PGClient, Pool } from 'pg'; +import { z } from 'zod'; +import { Logger } from '@graphql-hive/logger'; +import { Context } from './context.js'; + +export type WorkflowDefinition = { + name: TName; + schema: z.ZodTypeAny & { _output: TModel }; +}; + +export function defineWorkflow( + workflow: WorkflowDefinition, +): WorkflowDefinition { + return workflow; +} + +type StepFunctionArgs = { + id: string; + output: z.ZodTypeAny & { _output: TOutputModel }; +}; + +type WorkflowImplementationArgs = { + input: TPayload; + context: Context; + logger: Logger; + helpers: JobHelpers; + steps: { + run: (args: StepFunctionArgs, implementation: () => Promise) => Promise; + sleep: (id: string, amount: number) => Promise; + }; +}; + +class EnqueuedNextStep extends Error {} + +class ParallelTaskEnqueue extends Error { + stepIds: Array; + constructor(stepId: Array) { + super(); + this.stepIds = stepId; + } +} + +export function implementWorkflow( + workflowDefinition: WorkflowDefinition, + implementation: (args: WorkflowImplementationArgs) => Promise, +): (context: Context) => [string, Task] { + const schema = z.object({ + workflowId: z.string(), + input: workflowDefinition.schema, + }); + + return function (context) { + return [ + workflowDefinition.name, + async function (unsafePayload, helpers) { + const input = schema.parse(unsafePayload); + const steps = await helpers.withPgClient(pg => + getWorkflowStatus(pg as any, input.workflowId), + ); + + const logger = context.logger.child({ + 'workflow.id': input.workflowId, + 'workflow.name': workflowDefinition.name, + 'job.id': helpers.job.id, + 'job.queueId': helpers.job.job_queue_id, + 'job.attempts': helpers.job.attempts, + 'job.maxAttempts': helpers.job.max_attempts, + 'job.priority': helpers.job.priority, + 'job.taskId': helpers.job.task_id, + }); + + // Detection on whether we are running steps in parallel! + const pendingSteps: Array<{ + stepId: string; + promise: PromiseWithResolvers; + }> = []; + + let isFlush = false + + async function doFlush() { + const needsSchedule = + } + + async function run( + args: StepFunctionArgs, + implementation: () => Promise, + ): Promise { + const promise = Promise.withResolvers(); + + pendingSteps.push({ + stepId: args.id, + promise, + }); + + if (!isFlush) { + isFlush = true + Promise.resolve().then(doFlush) + } + + return await promise.promise; + + if (!step) { + } + + // check if step result already exists + if (args.id in steps) { + const stepPayload = steps[args.id].output; + + const parseResult = args.output.safeParse(stepPayload); + + if (parseResult.success) { + return parseResult.data; + } + + // special handling for void, since the key is omitted... + if (stepPayload === null) { + return; + } + + // TODO: handle inconsistency case! + } + + pendingSteps.push(args.id); + + // delay to next tick to gather parallel steps + await Promise.resolve(); + + // We only have one task? Let's run it! + if (pendingSteps.length === 1) { + if (logger.attrs) { + logger.attrs['workflow.step'] = args.id; + } + + const result = await implementation(); + + await helpers.withPgClient(client => + updateWorkflowStatus(client as any, input.workflowId, args.id, { + status: 'complete', + output: result ?? null, + }), + ); + + // add job for next steps! + await helpers.addJob(workflowDefinition.name, input); + + throw new EnqueuedNextStep(); + } + + if (!input.step) { + if (pendingSteps[0] === args.id) { + throw new ParallelTaskEnqueue(pendingSteps); + } + + // make sure other promises don't resolve... + // there should probably a better way... + return new Promise(() => {}); + } + + // Multiple tasks are a headache! + + if (input.step && input.step.id === args.id) { + if (logger.attrs) { + logger.attrs['workflow.step'] = args.id; + } + const result = await implementation(); + + // DO stufff + } else if (pendingSteps[0] === args.id) { + throw new ParallelTaskEnqueue(pendingSteps); + } + + // What to do here ?????? + } + + async function sleep(name: string, amount: number) { + const didSleep = input.sleeps[name] ?? false; + if (didSleep) { + return; + } + + const sleepUntil = new Date(new Date().getTime() + amount); + + logger.debug({ sleepUntil }, 'task will go to sleep'); + + await helpers.addJob( + workflowDefinition.name, + { + ...input, + steps: input.steps, + sleeps: { + ...input.sleeps, + [name]: true, + }, + }, + { + runAt: sleepUntil, + }, + ); + + throw new EnqueuedNextStep(); + } + + try { + return await implementation({ + input: input.input, + steps: { + run, + sleep, + }, + context, + helpers, + logger, + }); + } catch (err) { + if (err instanceof EnqueuedNextStep) { + return; + } + + if (err instanceof ParallelTaskEnqueue) { + for (const stepId of err.stepIds) { + await helpers.addJob( + workflowDefinition.name, + { + ...input, + step: { + siblings: err.stepIds.filter(id => id !== stepId), + id: stepId, + }, + } satisfies typeof input, + { + jobKey: `${input.workflowId}//${stepId}`, + }, + ); + } + } + + throw err; + } + }, + ]; + }; +} + +export async function queueWorkflow( + workflowDefinition: WorkflowDefinition, + payload: TPayload, +) { + const workflowId = crypto.randomUUID(); + const pool = new Pool({ + connectionString: 'postgresql://postgres:postgres@localhost:5432/postgres', + }); + await createWorkflow(pool, workflowId); + await pool.end(); + + await quickAddJob( + { + connectionString: 'postgresql://postgres:postgres@localhost:5432/postgres', + }, + workflowDefinition.name, + { + workflowId, + input: payload, + steps: {}, + sleeps: {}, + }, + ); +} + +// FAQ: + +// How can we achieve workflow consistency for named queues? +// +// initial task -> priority: 1 +// queued tasks -> priority: + 1 +// That way the queued tasks have HIGHER priority than other queued tasks + +// TODO: we need an extra table for steps attempts and results + +const StepModel = z.object({ + status: z.union([z.literal('err'), z.literal('pending'), z.literal('complete')]), + output: z.any(), +}); + +const StepsModel = z.record(z.string(), StepModel); + +async function createWorkflow(pg: Pool, workflowId: string) { + await pg.query( + `INSERT INTO graphile_worker._private_workflows("workflow_id", "steps") VALUES ($1::uuid, $2::jsonb)`, + [workflowId, JSON.stringify({})], + ); +} + +async function getWorkflowStatus(pg: PGClient, workflowId: string) { + const { rows } = await pg.query( + `SELECT "steps" FROM graphile_worker._private_workflows WHERE "workflow_id" = $1::uuid`, + [workflowId], + ); + return StepsModel.parse(rows[0].steps); +} + +async function updateWorkflowStatus( + pg: Pool, + workflowId: string, + stepName: string, + payload: z.TypeOf, +) { + const { rows } = await pg.query( + ` + INSERT INTO graphile_worker._private_workflows ( + "workflow_id", + "steps" + ) + VALUES ( + $1::uuid, + jsonb_build_object($2, $3::jsonb) + ) + ON CONFLICT ("workflow_id") + DO UPDATE SET + "steps" = jsonb_set( + graphile_worker._private_workflows."steps", + ARRAY[$2], + $3::jsonb, + true + ) + RETURNING "steps"; + `, + [workflowId, stepName, JSON.stringify(payload)], + ); + + return StepsModel.parse(rows[0].steps); +} diff --git a/packages/services/workflows/src/workflows/audit-log-export.ts b/packages/services/workflows/src/workflows/audit-log-export.ts deleted file mode 100644 index ceb788fc66a..00000000000 --- a/packages/services/workflows/src/workflows/audit-log-export.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod'; -import { declareWorkflow, workflow } from '../kit.js'; -import { renderAuditLogsReportEmail } from '../lib/emails/templates/audit-logs-report.js'; - -export const auditLogeExport = declareWorkflow({ - name: 'audit-log-export', - schema: z.object({ - organizationId: z.string(), - organizationName: z.string(), - formattedStartDate: z.string(), - formattedEndDate: z.string(), - url: z.string(), - email: z.string(), - }), -}); - -export const register = workflow(auditLogeExport, async args => { - // TODO: export audit log and store it - - await args.step.run({ name: 'send-email' }, async () => { - await args.context.email.send({ - to: args.input.email, - subject: 'Hive - Audit Log Report', - body: renderAuditLogsReportEmail({ - url: args.input.url, - organizationName: args.input.organizationName, - formattedStartDate: args.input.formattedStartDate, - formattedEndDate: args.input.formattedEndDate, - }), - }); - }); -}); diff --git a/packages/services/workflows/src/workflows/email-verification.ts b/packages/services/workflows/src/workflows/email-verification.ts deleted file mode 100644 index 30ee24c741d..00000000000 --- a/packages/services/workflows/src/workflows/email-verification.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from 'zod'; -import { declareWorkflow, workflow } from '../kit.js'; -import { renderEmailVerificationEmail } from '../lib/emails/templates/email-verification.js'; - -export const emailVerification = declareWorkflow({ - name: 'emailVerification', - schema: z.object({ - user: z.object({ - email: z.string(), - id: z.string(), - }), - emailVerifyLink: z.string(), - }), -}); - -export const register = workflow(emailVerification, async args => { - await args.step.run({ name: 'send-email' }, async () => { - await args.context.email.send({ - to: args.input.user.email, - subject: 'Verify your email', - body: renderEmailVerificationEmail({ - verificationLink: args.input.emailVerifyLink, - toEmail: args.input.user.email, - }), - }); - }); -}); diff --git a/packages/services/workflows/src/workflows/organization-invite.ts b/packages/services/workflows/src/workflows/organization-invite.ts deleted file mode 100644 index 24eccf33fd2..00000000000 --- a/packages/services/workflows/src/workflows/organization-invite.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from 'zod'; -import { declareWorkflow, workflow } from '../kit.js'; -import { renderOrganizationInvitation } from '../lib/emails/templates/organization-invitation.js'; - -export const organizationInvitation = declareWorkflow({ - name: 'organizationInvitation', - schema: z.object({ - organizationId: z.string(), - organizationName: z.string(), - code: z.string(), - email: z.string(), - link: z.string(), - }), -}); - -export const register = workflow(organizationInvitation, async args => { - await args.step.run({ name: 'send-email' }, async () => { - await args.context.email.send({ - to: args.input.email, - subject: `You have been invited to join ${args.input.organizationName}`, - body: renderOrganizationInvitation({ - link: args.input.link, - organizationName: args.input.organizationName, - }), - }); - }); -}); diff --git a/packages/services/workflows/src/workflows/organization-ownership-transfer.ts b/packages/services/workflows/src/workflows/organization-ownership-transfer.ts deleted file mode 100644 index c0fad91ebcc..00000000000 --- a/packages/services/workflows/src/workflows/organization-ownership-transfer.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { z } from 'zod'; -import { declareWorkflow, workflow } from '../kit.js'; -import { renderOrganizationOwnershipTransferEmail } from '../lib/emails/templates/organization-ownership-transfer.js'; - -export const organizationOwnershipTransfer = declareWorkflow({ - name: 'organizationOwnershipTransfer', - schema: z.object({ - organizationId: z.string(), - organizationName: z.string(), - authorName: z.string(), - email: z.string(), - link: z.string(), - }), -}); - -export const register = workflow(organizationOwnershipTransfer, async args => { - await args.step.run({ name: 'send-email' }, async () => { - await args.context.email.send({ - to: args.input.email, - subject: `Organization transfer from ${args.input.authorName} (${args.input.organizationName})`, - body: renderOrganizationOwnershipTransferEmail({ - link: args.input.link, - organizationName: args.input.organizationName, - authorName: args.input.authorName, - }), - }); - }); -}); diff --git a/packages/services/workflows/src/workflows/password-reset.ts b/packages/services/workflows/src/workflows/password-reset.ts deleted file mode 100644 index 7d468ce3773..00000000000 --- a/packages/services/workflows/src/workflows/password-reset.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from 'zod'; -import { declareWorkflow, workflow } from '../kit'; -import { renderPasswordResetEmail } from '../lib/emails/templates/password-reset'; - -export const passwordReset = declareWorkflow({ - name: 'passwordReset', - schema: z.object({ - user: z.object({ - email: z.string(), - id: z.string(), - }), - passwordResetLink: z.string(), - }), -}); - -export const register = workflow(passwordReset, async args => { - await args.step.run({ name: 'send-email' }, async () => { - await args.context.email.send({ - subject: `Reset your password`, - to: args.input.user.email, - body: renderPasswordResetEmail({ - passwordResetLink: args.input.passwordResetLink, - toEmail: args.input.user.email, - }), - }); - }); -}); diff --git a/packages/services/workflows/src/workflows/usage-rate-limit-exceeded.ts b/packages/services/workflows/src/workflows/usage-rate-limit-exceeded.ts deleted file mode 100644 index 2dffa486cb3..00000000000 --- a/packages/services/workflows/src/workflows/usage-rate-limit-exceeded.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod'; -import { declareWorkflow, workflow } from '../kit'; -import { renderRateLimitExceededEmail } from '../lib/emails/templates/rate-limit-exceeded'; - -export const usageRateLimitExceeded = declareWorkflow({ - name: 'usageRateLimitExceeded', - schema: z.object({ - organizationId: z.string(), - organizationName: z.string(), - limit: z.number(), - currentUsage: z.number(), - startDate: z.number(), - endDate: z.number(), - subscriptionManagementLink: z.string(), - email: z.string(), - }), -}); - -export const register = workflow(usageRateLimitExceeded, async args => { - await args.step.run({ name: 'send-email' }, async () => { - await args.context.email.send({ - subject: `GraphQL-Hive operations quota for ${args.input.organizationName} exceeded`, - to: args.input.email, - body: renderRateLimitExceededEmail({ - organizationName: args.input.organizationName, - currentUsage: args.input.currentUsage, - limit: args.input.limit, - subscriptionManagementLink: args.input.subscriptionManagementLink, - }), - }); - }); - - // TODO: Webhooks ?! -}); diff --git a/packages/services/workflows/src/workflows/usage-rate-limit-warning.ts b/packages/services/workflows/src/workflows/usage-rate-limit-warning.ts deleted file mode 100644 index 638481d3e82..00000000000 --- a/packages/services/workflows/src/workflows/usage-rate-limit-warning.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod'; -import { declareWorkflow, workflow } from '../kit'; -import { renderRateLimitWarningEmail } from '../lib/emails/templates/rate-limit-warning'; - -export const usageRateLimitWarning = declareWorkflow({ - name: 'usageRateLimitWarning', - schema: z.object({ - organizationId: z.string(), - organizationName: z.string(), - limit: z.number(), - currentUsage: z.number(), - startDate: z.number(), - endDate: z.number(), - subscriptionManagementLink: z.string(), - email: z.string(), - }), -}); - -export const register = workflow(usageRateLimitWarning, async args => { - await args.step.run({ name: 'send-email' }, async () => { - await args.context.email.send({ - subject: `GraphQL-Hive operations quota for ${args.input.organizationName} exceeded`, - to: args.input.email, - body: renderRateLimitWarningEmail({ - organizationName: args.input.organizationName, - limit: args.input.limit, - currentUsage: args.input.currentUsage, - subscriptionManagementLink: args.input.subscriptionManagementLink, - }), - }); - }); - - // TODO: Webhooks ?! -}); diff --git a/packages/services/workflows/src/workflows/user-onboarding.ts b/packages/services/workflows/src/workflows/user-onboarding.ts new file mode 100644 index 00000000000..8e4d15afca1 --- /dev/null +++ b/packages/services/workflows/src/workflows/user-onboarding.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import { defineWorkflow, implementWorkflow } from '../workflows.js'; + +export const UserOnboardingWorkflow = defineWorkflow({ + name: 'userOnboarding', + schema: z.object({ + organizationId: z.string(), + userId: z.string(), + }), +}); + +export const task = implementWorkflow(UserOnboardingWorkflow, async args => { + await args.steps.run( + { + id: 'step1', + output: z.void(), + }, + async () => { + args.logger.info('STEP 1'); + }, + ); + + // await args.steps.sleep('wait-ten-seconds', 10_000); + + // await args.steps.run( + // { + // id: 'step2', + // output: z.void(), + // }, + // async () => { + // args.logger.info('foo bars'); + // }, + // ); + + const [a, b] = await Promise.all([ + args.steps.run( + { + id: 'step3', + output: z.object({ email: z.string() }), + }, + async () => { + args.logger.info('step 3'); + return { email: 'foo@bars.de' }; + }, + ), + args.steps.run( + { + id: 'step4', + output: z.object({ + id: z.string(), + }), + }, + async () => { + args.logger.info('step 4'); + + return { id: '123' }; + }, + ), + ]); + + args.logger.info(`YOOO! the workflow run is finished! ${a?.email} -> ${b?.id}`); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a04ad10ce85..976108649c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,7 +227,7 @@ importers: version: 5.1.4(typescript@5.7.3)(vite@7.1.11(@types/node@22.10.5)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) vitest: specifier: 4.0.9 - version: 4.0.9(@types/node@22.10.5)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 4.0.9(@types/debug@4.1.12)(@types/node@22.10.5)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) deployment: dependencies: @@ -384,7 +384,7 @@ importers: version: 2.8.1 vitest: specifier: 4.0.9 - version: 4.0.9(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 4.0.9(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) zod: specifier: 3.25.76 version: 3.25.76 @@ -433,7 +433,7 @@ importers: version: 14.0.10 vitest: specifier: 4.0.9 - version: 4.0.9(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 4.0.9(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) ws: specifier: '>=8.18.0 || >=7.5.10 || >=6.2.3 || >=5.2.4' version: 8.18.0 @@ -574,7 +574,7 @@ importers: version: 2.8.1 vitest: specifier: 4.0.9 - version: 4.0.9(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 4.0.9(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) publishDirectory: dist packages/libraries/envelop: @@ -659,7 +659,7 @@ importers: version: 5.13.3(graphql@16.9.0) vitest: specifier: 4.0.9 - version: 4.0.9(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 4.0.9(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) ws: specifier: '>=8.18.0 || >=7.5.10 || >=6.2.3 || >=5.2.4' version: 8.18.0 @@ -916,7 +916,7 @@ importers: version: 6.21.3 vitest: specifier: 4.0.9 - version: 4.0.9(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 4.0.9(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) zod: specifier: 3.25.76 version: 3.25.76 @@ -949,7 +949,7 @@ importers: version: 6.21.3 vitest: specifier: 4.0.9 - version: 4.0.9(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 4.0.9(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) workers-loki-logger: specifier: 0.1.15 version: 0.1.15 @@ -1718,6 +1718,24 @@ importers: specifier: 3.25.76 version: 3.25.76 + packages/services/workflows: + dependencies: + '@graphql-hive/logger': + specifier: 1.0.9 + version: 1.0.9 + '@openworkflow/backend-postgres': + specifier: 0.3.0 + version: 0.3.0(openworkflow@0.3.0) + graphile-worker: + specifier: 0.16.6 + version: 0.16.6(typescript@5.7.3) + openworkflow: + specifier: 0.3.0 + version: 0.3.0 + zod: + specifier: 3.25.76 + version: 3.25.76 + packages/web/app: devDependencies: '@date-fns/utc': @@ -4177,6 +4195,9 @@ packages: graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 typescript: ^5.0.0 + '@graphile/logger@0.2.0': + resolution: {integrity: sha512-jjcWBokl9eb1gVJ85QmoaQ73CQ52xAaOCF29ukRbYNl6lY+ts0ErTaDYOBlejcbUs2OpaiqYLO5uDhyLFzWw4w==} + '@graphiql/plugin-explorer@4.0.0-alpha.2': resolution: {integrity: sha512-U3pAVaSX9lKUEIpOffJL0wV8S+T5be6qN/Med+p7Jmi6fCJcBsjzOreLf5bUBAaHYIaXAgMBQXCrNTL17lN4Ag==} peerDependencies: @@ -7330,6 +7351,11 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@openworkflow/backend-postgres@0.3.0': + resolution: {integrity: sha512-h7uE/+xrQpGpXeI0IaAy1Q+FN2SILIYX166R5kk47TleEYhRBF1JZ8jmZkmkqUapODxFp+BUZmTVmg3SctIIFg==} + peerDependencies: + openworkflow: ^0.3.0 + '@pagefind/darwin-arm64@1.3.0': resolution: {integrity: sha512-365BEGl6ChOsauRjyVpBjXybflXAOvoMROw3TucAROHIcdBvXk9/2AmEvGFU0r75+vdQI4LJdJdpH4Y6Yqaj4A==} cpu: [arm64] @@ -10295,6 +10321,9 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/debug@4.1.7': resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==} @@ -10353,6 +10382,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/interpret@1.1.4': + resolution: {integrity: sha512-r+tPKWHYqaxJOYA3Eik0mMi+SEREqOXLmsooRFmc6GHv7nWUDixFtKN+cegvsPlDcEZd9wxsdp041v2imQuvag==} + '@types/ioredis-mock@8.2.5': resolution: {integrity: sha512-cZyuwC9LGtg7s5G9/w6rpy3IOZ6F/hFR0pQlWYZESMo1xQUYbDpa6haqB4grTePjsGzcB/YLBFCjqRunK5wieg==} @@ -10458,6 +10490,9 @@ packages: '@types/node@22.10.5': resolution: {integrity: sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==} + '@types/node@22.19.1': + resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/node@24.10.1': resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} @@ -10541,6 +10576,9 @@ packages: '@types/semver@7.5.6': resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/send@0.17.5': resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} @@ -13539,6 +13577,15 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphile-config@0.0.1-beta.18: + resolution: {integrity: sha512-uMdF9Rt8/NwT1wVXNleYgM5ro2hHDodHiKA3efJhgdU8iP+r/hksnghOHreMva0sF5tV73f4TpiELPUR0g7O9w==} + engines: {node: '>=16'} + + graphile-worker@0.16.6: + resolution: {integrity: sha512-e7gGYDmGqzju2l83MpzX8vNG/lOtVJiSzI3eZpAFubSxh/cxs7sRrRGBGjzBP1kNG0H+c95etPpNRNlH65PYhw==} + engines: {node: '>=14.0.0'} + hasBin: true + graphiql-explorer@0.9.0: resolution: {integrity: sha512-fZC/wsuatqiQDO2otchxriFO0LaWIo/ovF/CQJ1yOudmY0P7pzDiP+l9CEHUiWbizk3e99x6DQG4XG1VxA+d6A==} peerDependencies: @@ -14055,6 +14102,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + intl-tel-input@17.0.19: resolution: {integrity: sha512-GBNoUT4JVgm2e1N+yFMaBQ24g5EQfZhDznGneCM9IEZwfKsMUAUa1dS+v0wOiKpRAZ5IPNLJMIEEFGgqlCI22A==} @@ -16056,6 +16107,10 @@ packages: resolution: {integrity: sha512-vz9iS7MJ5+Bp1URw8Khvdyw1H/hGvzHWlKQ7eRrQojSCDL1/SrWfrY9QebLw97n2deyRtzHRC3MkQfVNUCo91Q==} engines: {node: '>=0.10'} + openworkflow@0.3.0: + resolution: {integrity: sha512-eP3W7bvmcdllRZp3Xawh0iB2VKR4eyUML5D2yi87f2GDyFcrKMHCddM1tVxUgjaXBYa6zpTeJasbcSgrVTRsAQ==} + engines: {node: '>=20'} + optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -16603,6 +16658,10 @@ packages: postgres-range@1.1.3: resolution: {integrity: sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==} + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -18595,6 +18654,9 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -19679,11 +19741,11 @@ snapshots: '@ardatan/relay-compiler@12.0.0(encoding@0.1.13)(graphql@16.9.0)': dependencies: '@babel/core': 7.26.0 - '@babel/generator': 7.26.3 - '@babel/parser': 7.26.10 + '@babel/generator': 7.28.5 + '@babel/parser': 7.28.5 '@babel/runtime': 7.26.10 - '@babel/traverse': 7.26.4 - '@babel/types': 7.26.10 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 babel-preset-fbjs: 3.4.0(@babel/core@7.26.0) chalk: 4.1.2 fb-watchman: 2.0.2 @@ -19793,8 +19855,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19946,11 +20008,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': + '@aws-sdk/client-sso-oidc@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19989,7 +20051,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -20209,11 +20270,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0': + '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20252,6 +20313,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20483,7 +20545,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20730,7 +20792,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -21119,7 +21181,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 @@ -21416,7 +21478,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.22.5': dependencies: - '@babel/types': 7.26.10 + '@babel/types': 7.28.5 '@babel/helper-compilation-targets@7.25.9': dependencies: @@ -21449,23 +21511,23 @@ snapshots: '@babel/helper-environment-visitor@7.24.7': dependencies: - '@babel/types': 7.26.10 + '@babel/types': 7.28.5 '@babel/helper-function-name@7.24.7': dependencies: - '@babel/template': 7.26.9 - '@babel/types': 7.26.10 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 '@babel/helper-globals@7.28.0': {} '@babel/helper-member-expression-to-functions@7.24.5': dependencies: - '@babel/types': 7.26.10 + '@babel/types': 7.28.5 '@babel/helper-module-imports@7.25.9': dependencies: - '@babel/traverse': 7.26.4 - '@babel/types': 7.26.10 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -21481,7 +21543,7 @@ snapshots: '@babel/core': 7.22.9 '@babel/helper-module-imports': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.26.4 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -21490,7 +21552,7 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-module-imports': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.26.4 + '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -21505,7 +21567,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.22.5': dependencies: - '@babel/types': 7.26.10 + '@babel/types': 7.28.5 '@babel/helper-plugin-utils@7.25.9': {} @@ -21520,18 +21582,18 @@ snapshots: '@babel/helper-simple-access@7.25.7': dependencies: - '@babel/traverse': 7.26.4 - '@babel/types': 7.26.10 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.22.5': dependencies: - '@babel/types': 7.26.10 + '@babel/types': 7.28.5 '@babel/helper-split-export-declaration@7.24.7': dependencies: - '@babel/types': 7.26.10 + '@babel/types': 7.28.5 '@babel/helper-string-parser@7.25.9': {} @@ -21548,7 +21610,7 @@ snapshots: '@babel/helpers@7.26.10': dependencies: '@babel/template': 7.26.9 - '@babel/types': 7.26.10 + '@babel/types': 7.28.5 '@babel/helpers@7.28.4': dependencies: @@ -21557,7 +21619,7 @@ snapshots: '@babel/parser@7.26.10': dependencies: - '@babel/types': 7.26.10 + '@babel/types': 7.28.5 '@babel/parser@7.26.3': dependencies: @@ -21571,41 +21633,36 @@ snapshots: dependencies: '@babel/core': 7.26.0 '@babel/helper-create-class-features-plugin': 7.24.5(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-proposal-object-rest-spread@7.20.7(@babel/core@7.26.0)': dependencies: '@babel/compat-data': 7.26.3 '@babel/core': 7.26.0 '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.26.0) '@babel/plugin-transform-parameters': 7.24.5(@babel/core@7.26.0) '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-flow@7.22.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 - - '@babel/plugin-syntax-import-assertions@7.24.1(@babel/core@7.22.9)': - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-import-assertions@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-import-assertions@7.24.1(@babel/core@7.28.5)': + '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.22.9)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.28.5)': dependencies: @@ -21615,32 +21672,32 @@ snapshots: '@babel/plugin-syntax-jsx@7.23.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-jsx@7.23.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-arrow-functions@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-block-scoped-functions@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-block-scoping@7.24.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-classes@7.24.5(@babel/core@7.26.0)': dependencies: @@ -21649,7 +21706,7 @@ snapshots: '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-environment-visitor': 7.24.7 '@babel/helper-function-name': 7.24.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-replace-supers': 7.24.1(@babel/core@7.26.0) '@babel/helper-split-export-declaration': 7.24.7 globals: 11.12.0 @@ -21657,24 +21714,24 @@ snapshots: '@babel/plugin-transform-computed-properties@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/template': 7.26.9 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/template': 7.27.2 '@babel/plugin-transform-destructuring@7.24.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-flow-strip-types@7.22.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-flow': 7.22.5(@babel/core@7.26.0) '@babel/plugin-transform-for-of@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 '@babel/plugin-transform-function-name@7.24.1(@babel/core@7.26.0)': @@ -21682,23 +21739,23 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-function-name': 7.24.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-literals@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-member-expression-literals@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-modules-commonjs@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-simple-access': 7.25.7 transitivePeerDependencies: - supports-color @@ -21706,23 +21763,23 @@ snapshots: '@babel/plugin-transform-object-super@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-replace-supers': 7.24.1(@babel/core@7.26.0) '@babel/plugin-transform-parameters@7.24.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-property-literals@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-react-display-name@7.22.5(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.0)': dependencies: @@ -21739,27 +21796,27 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-annotate-as-pure': 7.22.5 '@babel/helper-module-imports': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-jsx': 7.23.3(@babel/core@7.26.0) - '@babel/types': 7.26.10 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color '@babel/plugin-transform-shorthand-properties@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-transform-spread@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 '@babel/plugin-transform-template-literals@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.27.1 '@babel/runtime@7.26.10': dependencies: @@ -22735,6 +22792,8 @@ snapshots: graphql: 16.9.0 typescript: 5.7.3 + '@graphile/logger@0.2.0': {} + '@graphiql/plugin-explorer@4.0.0-alpha.2(@graphiql/react@1.0.0-alpha.4(patch_hash=1018befc9149cbc43bc2bf8982d52090a580e68df34b46674234f4e58eb6d0a0)(@codemirror/language@6.10.2)(@types/node@24.10.1)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(graphql-ws@5.16.1(graphql@16.9.0))(graphql@16.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(graphql@16.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@graphiql/react': 1.0.0-alpha.4(patch_hash=1018befc9149cbc43bc2bf8982d52090a580e68df34b46674234f4e58eb6d0a0)(@codemirror/language@6.10.2)(@types/node@24.10.1)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(graphql-ws@5.16.1(graphql@16.9.0))(graphql@16.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -24441,10 +24500,10 @@ snapshots: '@graphql-tools/graphql-tag-pluck@7.5.2(@babel/core@7.28.5)(graphql@16.9.0)': dependencies: - '@babel/parser': 7.26.3 - '@babel/plugin-syntax-import-assertions': 7.24.1(@babel/core@7.28.5) - '@babel/traverse': 7.26.4 - '@babel/types': 7.26.3 + '@babel/parser': 7.28.5 + '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.28.5) + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 '@graphql-tools/utils': 9.2.1(graphql@16.9.0) graphql: 16.9.0 tslib: 2.8.1 @@ -24454,10 +24513,10 @@ snapshots: '@graphql-tools/graphql-tag-pluck@8.0.1(@babel/core@7.22.9)(graphql@16.9.0)': dependencies: - '@babel/parser': 7.26.10 - '@babel/plugin-syntax-import-assertions': 7.24.1(@babel/core@7.22.9) - '@babel/traverse': 7.26.4 - '@babel/types': 7.26.10 + '@babel/parser': 7.28.5 + '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.22.9) + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 '@graphql-tools/utils': 10.9.1(graphql@16.9.0) graphql: 16.9.0 tslib: 2.8.1 @@ -27828,6 +27887,11 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@openworkflow/backend-postgres@0.3.0(openworkflow@0.3.0)': + dependencies: + openworkflow: 0.3.0 + postgres: 3.4.7 + '@pagefind/darwin-arm64@1.3.0': optional: true @@ -31681,12 +31745,12 @@ snapshots: '@types/babel__generator@7.6.4': dependencies: - '@babel/types': 7.26.10 + '@babel/types': 7.28.5 '@types/babel__template@7.4.1': dependencies: - '@babel/parser': 7.26.10 - '@babel/types': 7.26.10 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@types/babel__traverse@7.18.3': dependencies: @@ -31861,6 +31925,10 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.12': + dependencies: + '@types/ms': 0.7.34 + '@types/debug@4.1.7': dependencies: '@types/ms': 0.7.34 @@ -31930,6 +31998,10 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/interpret@1.1.4': + dependencies: + '@types/node': 22.10.5 + '@types/ioredis-mock@8.2.5': dependencies: '@types/node': 22.10.5 @@ -32035,6 +32107,10 @@ snapshots: dependencies: undici-types: 6.20.0 + '@types/node@22.19.1': + dependencies: + undici-types: 6.21.0 + '@types/node@24.10.1': dependencies: undici-types: 7.16.0 @@ -32127,6 +32203,8 @@ snapshots: '@types/semver@7.5.6': {} + '@types/semver@7.7.1': {} + '@types/send@0.17.5': dependencies: '@types/mime': 1.3.5 @@ -35728,6 +35806,36 @@ snapshots: graphemer@1.4.0: {} + graphile-config@0.0.1-beta.18: + dependencies: + '@types/interpret': 1.1.4 + '@types/node': 22.19.1 + '@types/semver': 7.7.1 + chalk: 4.1.2 + debug: 4.4.3(supports-color@8.1.1) + interpret: 3.1.1 + semver: 7.7.2 + tslib: 2.8.1 + yargs: 17.7.2 + transitivePeerDependencies: + - supports-color + + graphile-worker@0.16.6(typescript@5.7.3): + dependencies: + '@graphile/logger': 0.2.0 + '@types/debug': 4.1.12 + '@types/pg': 8.11.10 + cosmiconfig: 8.3.6(typescript@5.7.3) + graphile-config: 0.0.1-beta.18 + json5: 2.2.3 + pg: 8.13.1 + tslib: 2.8.1 + yargs: 17.7.2 + transitivePeerDependencies: + - pg-native + - supports-color + - typescript + graphiql-explorer@0.9.0(graphql@16.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: graphql: 16.9.0 @@ -36468,6 +36576,8 @@ snapshots: internmap@2.0.3: {} + interpret@3.1.1: {} + intl-tel-input@17.0.19: {} invariant@2.2.4: @@ -37720,7 +37830,7 @@ snapshots: d3: 7.9.0 d3-sankey: 0.12.3 dagre-d3-es: 7.0.11 - dayjs: 1.11.13 + dayjs: 1.11.18 dompurify: 3.2.6 katex: 0.16.22 khroma: 2.1.0 @@ -39015,6 +39125,8 @@ snapshots: opentracing@0.14.7: {} + openworkflow@0.3.0: {} + optionator@0.9.3: dependencies: '@aashutoshrathi/word-wrap': 1.2.6 @@ -39202,7 +39314,7 @@ snapshots: parse-json@7.1.1: dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.27.1 error-ex: 1.3.2 json-parse-even-better-errors: 3.0.0 lines-and-columns: 2.0.3 @@ -39595,6 +39707,8 @@ snapshots: postgres-range@1.1.3: {} + postgres@3.4.7: {} + prelude-ls@1.2.1: {} prettier-plugin-pkg@0.18.0(prettier@3.4.2): @@ -41884,6 +41998,8 @@ snapshots: undici-types@6.20.0: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} undici@5.29.0: @@ -42332,7 +42448,7 @@ snapshots: tsx: 4.19.2 yaml: 2.5.0 - vitest@4.0.9(@types/node@22.10.5)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): + vitest@4.0.9(@types/debug@4.1.12)(@types/node@22.10.5)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): dependencies: '@vitest/expect': 4.0.9 '@vitest/mocker': 4.0.9(vite@7.1.11(@types/node@22.10.5)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) @@ -42355,6 +42471,7 @@ snapshots: vite: 7.1.11(@types/node@22.10.5)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/debug': 4.1.12 '@types/node': 22.10.5 transitivePeerDependencies: - jiti @@ -42370,7 +42487,7 @@ snapshots: - tsx - yaml - vitest@4.0.9(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): + vitest@4.0.9(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): dependencies: '@vitest/expect': 4.0.9 '@vitest/mocker': 4.0.9(vite@7.1.11(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) @@ -42393,6 +42510,7 @@ snapshots: vite: 7.1.11(@types/node@24.10.1)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/debug': 4.1.12 '@types/node': 24.10.1 transitivePeerDependencies: - jiti From 616b62736437d94e4f4ef00c7d5fe24effd6cb51 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 4 Dec 2025 16:35:11 +0100 Subject: [PATCH 03/37] work twerk --- packages/services/workflows/package.json | 2 - packages/services/workflows/src/kit.ts | 114 +++--- packages/services/workflows/src/lol.ts | 7 - .../workflows/src/postgraphile-kit.ts | 55 --- .../workflows/src/tasks/audit-log-export.ts | 2 +- .../workflows/src/tasks/email-verification.ts | 2 +- .../src/tasks/organization-invite.ts | 2 +- .../tasks/organization-ownership-transfer.ts | 2 +- .../workflows/src/tasks/password-reset.ts | 2 +- .../src/tasks/purge-expired-schema-checks.ts | 2 +- .../src/tasks/schema-change-notification.ts | 2 +- .../src/tasks/usage-rate-limit-exceeded.ts | 2 +- .../src/tasks/usage-rate-limit-warning.ts | 2 +- packages/services/workflows/src/workflows.ts | 332 ------------------ .../src/workflows/user-onboarding.ts | 62 ---- pnpm-lock.yaml | 48 +-- 16 files changed, 68 insertions(+), 570 deletions(-) delete mode 100644 packages/services/workflows/src/lol.ts delete mode 100644 packages/services/workflows/src/postgraphile-kit.ts delete mode 100644 packages/services/workflows/src/workflows.ts delete mode 100644 packages/services/workflows/src/workflows/user-onboarding.ts diff --git a/packages/services/workflows/package.json b/packages/services/workflows/package.json index c02889fdbee..57e5b4e3ea9 100644 --- a/packages/services/workflows/package.json +++ b/packages/services/workflows/package.json @@ -10,9 +10,7 @@ }, "dependencies": { "@graphql-hive/logger": "1.0.9", - "@openworkflow/backend-postgres": "0.3.0", "graphile-worker": "0.16.6", - "openworkflow": "0.3.0", "zod": "3.25.76" } } diff --git a/packages/services/workflows/src/kit.ts b/packages/services/workflows/src/kit.ts index 4f5b93d0dfe..7f66920a7f7 100644 --- a/packages/services/workflows/src/kit.ts +++ b/packages/services/workflows/src/kit.ts @@ -1,71 +1,55 @@ -import type { OpenWorkflow } from 'openworkflow'; -import type { - WorkflowDefinitionConfig as InternalWorkflowDefinitionConfig, - StepFunctionConfig, - WorkflowDefinition, - WorkflowRunHandle, -} from 'openworkflow/dist/client'; -import { DurationString } from 'openworkflow/dist/duration.js'; -import type { ZodType } from 'zod'; -import type { Context } from './context.js'; - -type WorkflowDefinitionConfig<$Schema = unknown> = InternalWorkflowDefinitionConfig & { - schema: ZodType<$Schema>; +import { JobHelpers, Task } from 'graphile-worker'; +import { z } from 'zod'; +import { Logger } from '@graphql-hive/logger'; +import { Context } from './context'; + +export type TaskDefinition = { + name: TName; + schema: z.ZodTypeAny & { _output: TModel }; }; -export function declareWorkflow<$Schema = unknown>(args: WorkflowDefinitionConfig<$Schema>) { - return args; -} - -type StepFunction = () => Promise | Output | undefined; - -interface WorkflowFunctionParams { - input: Input; - step: StepApi; - version: string | null; -} - -interface StepApi { - run(config: StepFunctionConfig, fn: StepFunction): Promise; - sleep(name: string, duration: DurationString): Promise; +export function defineTask( + workflow: TaskDefinition, +): TaskDefinition { + return workflow; } -// Task Logging Todos: unique task ID -// Inject logger instance with all necessary prefixes (step; etc.) - -/** - * Implement a workflow. - */ -export function workflow<$Schema = unknown>( - config: WorkflowDefinitionConfig<$Schema>, - implementation: ( - args: WorkflowFunctionParams<$Schema> & { context: Context }, - ) => Promise, -) { - return (ow: OpenWorkflow, context: Context) => - ow.defineWorkflow<$Schema, unknown>(config, args => { - return implementation({ ...args, context }); - }); -} - -async function noop() {} - -const scheduleWorkflowCache = new Map['run']>(); - -/** - * Schedule a workflow run from application code. - */ -export function scheduleWorkflow<$Schema>( - ow: OpenWorkflow, - config: WorkflowDefinitionConfig<$Schema>, - input: $Schema, -): Promise> { - let run = scheduleWorkflowCache.get(config.name); - if (!run) { - const definition = ow.defineWorkflow(config, noop); - run = input => definition.run(config.schema.parse(input)); - scheduleWorkflowCache.set(config.name, run); - } +type TaskImplementationArgs = { + input: TPayload; + context: Context; + logger: Logger; + helpers: JobHelpers; +}; - return run(input); +export type TaskImplementation = ( + args: TaskImplementationArgs, +) => Promise; + +export function implementTask( + taskDefinition: TaskDefinition, + implementation: TaskImplementation, +): (context: Context) => [string, Task] { + return function (context) { + return [ + taskDefinition.name, + function (unsafePayload, helpers) { + const input = taskDefinition.schema.parse(unsafePayload); + return implementation({ + input, + context, + helpers, + logger: context.logger.child({ + attrs: { + 'job.id': helpers.job.id, + 'job.queueId': helpers.job.job_queue_id, + 'job.attempts': helpers.job.attempts, + 'job.maxAttempts': helpers.job.max_attempts, + 'job.priority': helpers.job.priority, + 'job.taskId': helpers.job.task_id, + }, + }), + }); + }, + ]; + }; } diff --git a/packages/services/workflows/src/lol.ts b/packages/services/workflows/src/lol.ts deleted file mode 100644 index c1a1e957944..00000000000 --- a/packages/services/workflows/src/lol.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { queueWorkflow } from './workflows.js'; -import { UserOnboardingWorkflow } from './workflows/user-onboarding.js'; - -queueWorkflow(UserOnboardingWorkflow, { - organizationId: 'abc', - userId: 'xyz', -}); diff --git a/packages/services/workflows/src/postgraphile-kit.ts b/packages/services/workflows/src/postgraphile-kit.ts deleted file mode 100644 index 7f66920a7f7..00000000000 --- a/packages/services/workflows/src/postgraphile-kit.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { JobHelpers, Task } from 'graphile-worker'; -import { z } from 'zod'; -import { Logger } from '@graphql-hive/logger'; -import { Context } from './context'; - -export type TaskDefinition = { - name: TName; - schema: z.ZodTypeAny & { _output: TModel }; -}; - -export function defineTask( - workflow: TaskDefinition, -): TaskDefinition { - return workflow; -} - -type TaskImplementationArgs = { - input: TPayload; - context: Context; - logger: Logger; - helpers: JobHelpers; -}; - -export type TaskImplementation = ( - args: TaskImplementationArgs, -) => Promise; - -export function implementTask( - taskDefinition: TaskDefinition, - implementation: TaskImplementation, -): (context: Context) => [string, Task] { - return function (context) { - return [ - taskDefinition.name, - function (unsafePayload, helpers) { - const input = taskDefinition.schema.parse(unsafePayload); - return implementation({ - input, - context, - helpers, - logger: context.logger.child({ - attrs: { - 'job.id': helpers.job.id, - 'job.queueId': helpers.job.job_queue_id, - 'job.attempts': helpers.job.attempts, - 'job.maxAttempts': helpers.job.max_attempts, - 'job.priority': helpers.job.priority, - 'job.taskId': helpers.job.task_id, - }, - }), - }); - }, - ]; - }; -} diff --git a/packages/services/workflows/src/tasks/audit-log-export.ts b/packages/services/workflows/src/tasks/audit-log-export.ts index 0d51455b35a..28038d135e5 100644 --- a/packages/services/workflows/src/tasks/audit-log-export.ts +++ b/packages/services/workflows/src/tasks/audit-log-export.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; import { renderAuditLogsReportEmail } from '../lib/emails/templates/audit-logs-report.js'; -import { defineTask, implementTask } from '../postgraphile-kit.js'; export const AuditLogExportTask = defineTask({ name: 'audit-log-export', diff --git a/packages/services/workflows/src/tasks/email-verification.ts b/packages/services/workflows/src/tasks/email-verification.ts index 25271faf9ed..1b06305648f 100644 --- a/packages/services/workflows/src/tasks/email-verification.ts +++ b/packages/services/workflows/src/tasks/email-verification.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; import { renderEmailVerificationEmail } from '../lib/emails/templates/email-verification.js'; -import { defineTask, implementTask } from '../postgraphile-kit.js'; export const EmailVerificationTask = defineTask({ name: 'emailVerification', diff --git a/packages/services/workflows/src/tasks/organization-invite.ts b/packages/services/workflows/src/tasks/organization-invite.ts index 2a69253b0e6..4c6458183d7 100644 --- a/packages/services/workflows/src/tasks/organization-invite.ts +++ b/packages/services/workflows/src/tasks/organization-invite.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; import { renderOrganizationInvitation } from '../lib/emails/templates/organization-invitation.js'; -import { defineTask, implementTask } from '../postgraphile-kit.js'; export const OrganizationInvitationTask = defineTask({ name: 'organizationInvitation', diff --git a/packages/services/workflows/src/tasks/organization-ownership-transfer.ts b/packages/services/workflows/src/tasks/organization-ownership-transfer.ts index c44ce19ca07..a7a105ef458 100644 --- a/packages/services/workflows/src/tasks/organization-ownership-transfer.ts +++ b/packages/services/workflows/src/tasks/organization-ownership-transfer.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; import { renderOrganizationOwnershipTransferEmail } from '../lib/emails/templates/organization-ownership-transfer.js'; -import { defineTask, implementTask } from '../postgraphile-kit.js'; export const OrganizationOwnershipTransferTask = defineTask({ name: 'organizationOwnershipTransfer', diff --git a/packages/services/workflows/src/tasks/password-reset.ts b/packages/services/workflows/src/tasks/password-reset.ts index a02696ee01d..7286fb94aed 100644 --- a/packages/services/workflows/src/tasks/password-reset.ts +++ b/packages/services/workflows/src/tasks/password-reset.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; import { renderPasswordResetEmail } from '../lib/emails/templates/password-reset'; -import { defineTask, implementTask } from '../postgraphile-kit.js'; export const PasswordResetTask = defineTask({ name: 'passwordReset', diff --git a/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts b/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts index 0dcb0a04378..0221e377f65 100644 --- a/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts +++ b/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; import { purgeExpiredSchemaChecks } from '../lib/expired-schema-checks'; -import { defineTask, implementTask } from '../postgraphile-kit.js'; export const PurgeExpiredSchemaChecks = defineTask({ name: 'purgeExpiredSchemaChecks', diff --git a/packages/services/workflows/src/tasks/schema-change-notification.ts b/packages/services/workflows/src/tasks/schema-change-notification.ts index be9cb018abc..9dbfbe963db 100644 --- a/packages/services/workflows/src/tasks/schema-change-notification.ts +++ b/packages/services/workflows/src/tasks/schema-change-notification.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { defineTask, implementTask } from '../postgraphile-kit.js'; +import { defineTask, implementTask } from '../kit.js'; export const SchemaChangeNotificationTask = defineTask({ name: 'schemaChangeNotification', diff --git a/packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts b/packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts index 565d549f973..5db2ac22e12 100644 --- a/packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts +++ b/packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; import { renderRateLimitExceededEmail } from '../lib/emails/templates/rate-limit-exceeded'; -import { defineTask, implementTask } from '../postgraphile-kit.js'; export const UsageRateLimitExceededTask = defineTask({ name: 'usageRateLimitExceeded', diff --git a/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts b/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts index 51a4b783fd6..cd7ee716a32 100644 --- a/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts +++ b/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; import { renderRateLimitWarningEmail } from '../lib/emails/templates/rate-limit-warning'; -import { defineTask, implementTask } from '../postgraphile-kit.js'; export const UsageRateLimitWarningTask = defineTask({ name: 'usageRateLimitWarning', diff --git a/packages/services/workflows/src/workflows.ts b/packages/services/workflows/src/workflows.ts deleted file mode 100644 index 7a6375a15b2..00000000000 --- a/packages/services/workflows/src/workflows.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { JobHelpers, quickAddJob, Task } from 'graphile-worker'; -import { Client as PGClient, Pool } from 'pg'; -import { z } from 'zod'; -import { Logger } from '@graphql-hive/logger'; -import { Context } from './context.js'; - -export type WorkflowDefinition = { - name: TName; - schema: z.ZodTypeAny & { _output: TModel }; -}; - -export function defineWorkflow( - workflow: WorkflowDefinition, -): WorkflowDefinition { - return workflow; -} - -type StepFunctionArgs = { - id: string; - output: z.ZodTypeAny & { _output: TOutputModel }; -}; - -type WorkflowImplementationArgs = { - input: TPayload; - context: Context; - logger: Logger; - helpers: JobHelpers; - steps: { - run: (args: StepFunctionArgs, implementation: () => Promise) => Promise; - sleep: (id: string, amount: number) => Promise; - }; -}; - -class EnqueuedNextStep extends Error {} - -class ParallelTaskEnqueue extends Error { - stepIds: Array; - constructor(stepId: Array) { - super(); - this.stepIds = stepId; - } -} - -export function implementWorkflow( - workflowDefinition: WorkflowDefinition, - implementation: (args: WorkflowImplementationArgs) => Promise, -): (context: Context) => [string, Task] { - const schema = z.object({ - workflowId: z.string(), - input: workflowDefinition.schema, - }); - - return function (context) { - return [ - workflowDefinition.name, - async function (unsafePayload, helpers) { - const input = schema.parse(unsafePayload); - const steps = await helpers.withPgClient(pg => - getWorkflowStatus(pg as any, input.workflowId), - ); - - const logger = context.logger.child({ - 'workflow.id': input.workflowId, - 'workflow.name': workflowDefinition.name, - 'job.id': helpers.job.id, - 'job.queueId': helpers.job.job_queue_id, - 'job.attempts': helpers.job.attempts, - 'job.maxAttempts': helpers.job.max_attempts, - 'job.priority': helpers.job.priority, - 'job.taskId': helpers.job.task_id, - }); - - // Detection on whether we are running steps in parallel! - const pendingSteps: Array<{ - stepId: string; - promise: PromiseWithResolvers; - }> = []; - - let isFlush = false - - async function doFlush() { - const needsSchedule = - } - - async function run( - args: StepFunctionArgs, - implementation: () => Promise, - ): Promise { - const promise = Promise.withResolvers(); - - pendingSteps.push({ - stepId: args.id, - promise, - }); - - if (!isFlush) { - isFlush = true - Promise.resolve().then(doFlush) - } - - return await promise.promise; - - if (!step) { - } - - // check if step result already exists - if (args.id in steps) { - const stepPayload = steps[args.id].output; - - const parseResult = args.output.safeParse(stepPayload); - - if (parseResult.success) { - return parseResult.data; - } - - // special handling for void, since the key is omitted... - if (stepPayload === null) { - return; - } - - // TODO: handle inconsistency case! - } - - pendingSteps.push(args.id); - - // delay to next tick to gather parallel steps - await Promise.resolve(); - - // We only have one task? Let's run it! - if (pendingSteps.length === 1) { - if (logger.attrs) { - logger.attrs['workflow.step'] = args.id; - } - - const result = await implementation(); - - await helpers.withPgClient(client => - updateWorkflowStatus(client as any, input.workflowId, args.id, { - status: 'complete', - output: result ?? null, - }), - ); - - // add job for next steps! - await helpers.addJob(workflowDefinition.name, input); - - throw new EnqueuedNextStep(); - } - - if (!input.step) { - if (pendingSteps[0] === args.id) { - throw new ParallelTaskEnqueue(pendingSteps); - } - - // make sure other promises don't resolve... - // there should probably a better way... - return new Promise(() => {}); - } - - // Multiple tasks are a headache! - - if (input.step && input.step.id === args.id) { - if (logger.attrs) { - logger.attrs['workflow.step'] = args.id; - } - const result = await implementation(); - - // DO stufff - } else if (pendingSteps[0] === args.id) { - throw new ParallelTaskEnqueue(pendingSteps); - } - - // What to do here ?????? - } - - async function sleep(name: string, amount: number) { - const didSleep = input.sleeps[name] ?? false; - if (didSleep) { - return; - } - - const sleepUntil = new Date(new Date().getTime() + amount); - - logger.debug({ sleepUntil }, 'task will go to sleep'); - - await helpers.addJob( - workflowDefinition.name, - { - ...input, - steps: input.steps, - sleeps: { - ...input.sleeps, - [name]: true, - }, - }, - { - runAt: sleepUntil, - }, - ); - - throw new EnqueuedNextStep(); - } - - try { - return await implementation({ - input: input.input, - steps: { - run, - sleep, - }, - context, - helpers, - logger, - }); - } catch (err) { - if (err instanceof EnqueuedNextStep) { - return; - } - - if (err instanceof ParallelTaskEnqueue) { - for (const stepId of err.stepIds) { - await helpers.addJob( - workflowDefinition.name, - { - ...input, - step: { - siblings: err.stepIds.filter(id => id !== stepId), - id: stepId, - }, - } satisfies typeof input, - { - jobKey: `${input.workflowId}//${stepId}`, - }, - ); - } - } - - throw err; - } - }, - ]; - }; -} - -export async function queueWorkflow( - workflowDefinition: WorkflowDefinition, - payload: TPayload, -) { - const workflowId = crypto.randomUUID(); - const pool = new Pool({ - connectionString: 'postgresql://postgres:postgres@localhost:5432/postgres', - }); - await createWorkflow(pool, workflowId); - await pool.end(); - - await quickAddJob( - { - connectionString: 'postgresql://postgres:postgres@localhost:5432/postgres', - }, - workflowDefinition.name, - { - workflowId, - input: payload, - steps: {}, - sleeps: {}, - }, - ); -} - -// FAQ: - -// How can we achieve workflow consistency for named queues? -// -// initial task -> priority: 1 -// queued tasks -> priority: + 1 -// That way the queued tasks have HIGHER priority than other queued tasks - -// TODO: we need an extra table for steps attempts and results - -const StepModel = z.object({ - status: z.union([z.literal('err'), z.literal('pending'), z.literal('complete')]), - output: z.any(), -}); - -const StepsModel = z.record(z.string(), StepModel); - -async function createWorkflow(pg: Pool, workflowId: string) { - await pg.query( - `INSERT INTO graphile_worker._private_workflows("workflow_id", "steps") VALUES ($1::uuid, $2::jsonb)`, - [workflowId, JSON.stringify({})], - ); -} - -async function getWorkflowStatus(pg: PGClient, workflowId: string) { - const { rows } = await pg.query( - `SELECT "steps" FROM graphile_worker._private_workflows WHERE "workflow_id" = $1::uuid`, - [workflowId], - ); - return StepsModel.parse(rows[0].steps); -} - -async function updateWorkflowStatus( - pg: Pool, - workflowId: string, - stepName: string, - payload: z.TypeOf, -) { - const { rows } = await pg.query( - ` - INSERT INTO graphile_worker._private_workflows ( - "workflow_id", - "steps" - ) - VALUES ( - $1::uuid, - jsonb_build_object($2, $3::jsonb) - ) - ON CONFLICT ("workflow_id") - DO UPDATE SET - "steps" = jsonb_set( - graphile_worker._private_workflows."steps", - ARRAY[$2], - $3::jsonb, - true - ) - RETURNING "steps"; - `, - [workflowId, stepName, JSON.stringify(payload)], - ); - - return StepsModel.parse(rows[0].steps); -} diff --git a/packages/services/workflows/src/workflows/user-onboarding.ts b/packages/services/workflows/src/workflows/user-onboarding.ts deleted file mode 100644 index 8e4d15afca1..00000000000 --- a/packages/services/workflows/src/workflows/user-onboarding.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { z } from 'zod'; -import { defineWorkflow, implementWorkflow } from '../workflows.js'; - -export const UserOnboardingWorkflow = defineWorkflow({ - name: 'userOnboarding', - schema: z.object({ - organizationId: z.string(), - userId: z.string(), - }), -}); - -export const task = implementWorkflow(UserOnboardingWorkflow, async args => { - await args.steps.run( - { - id: 'step1', - output: z.void(), - }, - async () => { - args.logger.info('STEP 1'); - }, - ); - - // await args.steps.sleep('wait-ten-seconds', 10_000); - - // await args.steps.run( - // { - // id: 'step2', - // output: z.void(), - // }, - // async () => { - // args.logger.info('foo bars'); - // }, - // ); - - const [a, b] = await Promise.all([ - args.steps.run( - { - id: 'step3', - output: z.object({ email: z.string() }), - }, - async () => { - args.logger.info('step 3'); - return { email: 'foo@bars.de' }; - }, - ), - args.steps.run( - { - id: 'step4', - output: z.object({ - id: z.string(), - }), - }, - async () => { - args.logger.info('step 4'); - - return { id: '123' }; - }, - ), - ]); - - args.logger.info(`YOOO! the workflow run is finished! ${a?.email} -> ${b?.id}`); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 976108649c7..b561f48ae11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1723,15 +1723,9 @@ importers: '@graphql-hive/logger': specifier: 1.0.9 version: 1.0.9 - '@openworkflow/backend-postgres': - specifier: 0.3.0 - version: 0.3.0(openworkflow@0.3.0) graphile-worker: specifier: 0.16.6 version: 0.16.6(typescript@5.7.3) - openworkflow: - specifier: 0.3.0 - version: 0.3.0 zod: specifier: 3.25.76 version: 3.25.76 @@ -7351,11 +7345,6 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 - '@openworkflow/backend-postgres@0.3.0': - resolution: {integrity: sha512-h7uE/+xrQpGpXeI0IaAy1Q+FN2SILIYX166R5kk47TleEYhRBF1JZ8jmZkmkqUapODxFp+BUZmTVmg3SctIIFg==} - peerDependencies: - openworkflow: ^0.3.0 - '@pagefind/darwin-arm64@1.3.0': resolution: {integrity: sha512-365BEGl6ChOsauRjyVpBjXybflXAOvoMROw3TucAROHIcdBvXk9/2AmEvGFU0r75+vdQI4LJdJdpH4Y6Yqaj4A==} cpu: [arm64] @@ -16107,10 +16096,6 @@ packages: resolution: {integrity: sha512-vz9iS7MJ5+Bp1URw8Khvdyw1H/hGvzHWlKQ7eRrQojSCDL1/SrWfrY9QebLw97n2deyRtzHRC3MkQfVNUCo91Q==} engines: {node: '>=0.10'} - openworkflow@0.3.0: - resolution: {integrity: sha512-eP3W7bvmcdllRZp3Xawh0iB2VKR4eyUML5D2yi87f2GDyFcrKMHCddM1tVxUgjaXBYa6zpTeJasbcSgrVTRsAQ==} - engines: {node: '>=20'} - optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -16658,10 +16643,6 @@ packages: postgres-range@1.1.3: resolution: {integrity: sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==} - postgres@3.4.7: - resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} - engines: {node: '>=12'} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -19855,8 +19836,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20008,11 +19989,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0': + '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20051,6 +20032,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -20270,11 +20252,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': + '@aws-sdk/client-sts@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20313,7 +20295,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20545,7 +20526,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20792,7 +20773,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -21181,7 +21162,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 @@ -27887,11 +27868,6 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@openworkflow/backend-postgres@0.3.0(openworkflow@0.3.0)': - dependencies: - openworkflow: 0.3.0 - postgres: 3.4.7 - '@pagefind/darwin-arm64@1.3.0': optional: true @@ -39125,8 +39101,6 @@ snapshots: opentracing@0.14.7: {} - openworkflow@0.3.0: {} - optionator@0.9.3: dependencies: '@aashutoshrathi/word-wrap': 1.2.6 @@ -39707,8 +39681,6 @@ snapshots: postgres-range@1.1.3: {} - postgres@3.4.7: {} - prelude-ls@1.2.1: {} prettier-plugin-pkg@0.18.0(prettier@3.4.2): From f99fa7db038db776a80c30a726ee5dd1387f934a Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Mon, 8 Dec 2025 11:49:07 +0100 Subject: [PATCH 04/37] wip --- packages/services/workflows/src/context.ts | 2 + .../services/workflows/src/environment.ts | 215 +++++++++++++++++- packages/services/workflows/src/index.ts | 30 ++- .../src/tasks/schema-change-notification.ts | 5 +- 4 files changed, 238 insertions(+), 14 deletions(-) diff --git a/packages/services/workflows/src/context.ts b/packages/services/workflows/src/context.ts index ceed0ed8e3c..b7bd4fb9e4d 100644 --- a/packages/services/workflows/src/context.ts +++ b/packages/services/workflows/src/context.ts @@ -1,9 +1,11 @@ import type { DatabasePool } from 'slonik'; import type { Logger } from '@graphql-hive/logger'; import type { EmailProvider } from './lib/emails/providers'; +import { RequestBroker } from './lib/webhooks/schema-change-notification'; export type Context = { logger: Logger; email: EmailProvider; pg: DatabasePool; + requestBroker: RequestBroker | null; }; diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index 30c8f411e56..cd9db91b36f 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -1,3 +1,214 @@ -import { z } from 'zod'; +import zod from 'zod'; +import { OpenTelemetryConfigurationModel } from '@hive/service-common'; +import { createConnectionString } from '@hive/storage'; -export const env = {}; +const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; + +const numberFromNumberOrNumberString = (input: unknown): number | undefined => { + if (typeof input == 'number') return input; + if (isNumberString(input)) return Number(input); +}; + +const NumberFromString = zod.preprocess(numberFromNumberOrNumberString, zod.number().min(1)); + +// treat an empty string (`''`) as undefined +const emptyString = (input: T) => { + return zod.preprocess((value: unknown) => { + if (value === '') return undefined; + return value; + }, input); +}; + +const EnvironmentModel = zod.object({ + PORT: emptyString(NumberFromString.optional()), + ENVIRONMENT: emptyString(zod.string().optional()), + RELEASE: emptyString(zod.string().optional()), + HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()), + EMAIL_FROM: zod.string().email(), +}); + +const SentryModel = zod.union([ + zod.object({ + SENTRY: emptyString(zod.literal('0').optional()), + }), + zod.object({ + SENTRY: zod.literal('1'), + SENTRY_DSN: zod.string(), + }), +]); + +const PostgresModel = zod.object({ + POSTGRES_SSL: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), + POSTGRES_HOST: zod.string(), + POSTGRES_PORT: NumberFromString, + POSTGRES_DB: zod.string(), + POSTGRES_USER: zod.string(), + POSTGRES_PASSWORD: emptyString(zod.string().optional()), +}); + +const PostmarkEmailModel = zod.object({ + EMAIL_PROVIDER: zod.literal('postmark'), + EMAIL_PROVIDER_POSTMARK_TOKEN: zod.string(), + EMAIL_PROVIDER_POSTMARK_MESSAGE_STREAM: zod.string(), +}); + +const SMTPEmailModel = zod.object({ + EMAIL_PROVIDER: zod.literal('smtp'), + EMAIL_PROVIDER_SMTP_PROTOCOL: emptyString( + zod.union([zod.literal('smtp'), zod.literal('smtps')]).optional(), + ), + EMAIL_PROVIDER_SMTP_HOST: zod.string(), + EMAIL_PROVIDER_SMTP_PORT: NumberFromString, + EMAIL_PROVIDER_SMTP_AUTH_USERNAME: zod.string(), + EMAIL_PROVIDER_SMTP_AUTH_PASSWORD: zod.string(), + EMAIL_PROVIDER_SMTP_REJECT_UNAUTHORIZED: emptyString( + zod.union([zod.literal('0'), zod.literal('1')]).optional(), + ), +}); + +const SendmailEmailModel = zod.object({ + EMAIL_PROVIDER: zod.literal('sendmail'), +}); + +const MockEmailProviderModel = zod.object({ + EMAIL_PROVIDER: zod.literal('mock'), +}); + +const EmailProviderModel = zod.union([ + PostmarkEmailModel, + MockEmailProviderModel, + SMTPEmailModel, + SendmailEmailModel, +]); + +const PrometheusModel = zod.object({ + PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), + PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()), + PROMETHEUS_METRICS_PORT: emptyString(NumberFromString.optional()), +}); + +const LogModel = zod.object({ + LOG_LEVEL: emptyString( + zod + .union([ + zod.literal('trace'), + zod.literal('debug'), + zod.literal('info'), + zod.literal('warn'), + zod.literal('error'), + ]) + .optional(), + ), + REQUEST_LOGGING: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()).default( + '1', + ), +}); + +const configs = { + base: EnvironmentModel.safeParse(process.env), + email: EmailProviderModel.safeParse(process.env), + sentry: SentryModel.safeParse(process.env), + postgres: PostgresModel.safeParse(process.env), + prometheus: PrometheusModel.safeParse(process.env), + log: LogModel.safeParse(process.env), + tracing: OpenTelemetryConfigurationModel.safeParse(process.env), +}; + +const environmentErrors: Array = []; + +for (const config of Object.values(configs)) { + if (config.success === false) { + environmentErrors.push(JSON.stringify(config.error.format(), null, 4)); + } +} + +if (environmentErrors.length) { + const fullError = environmentErrors.join(`\n`); + console.error('❌ Invalid environment variables:', fullError); + process.exit(1); +} + +function extractConfig(config: zod.SafeParseReturnType): Output { + if (!config.success) { + throw new Error('Something went wrong.'); + } + return config.data; +} + +const base = extractConfig(configs.base); +const email = extractConfig(configs.email); +const postgres = extractConfig(configs.postgres); +const sentry = extractConfig(configs.sentry); +const prometheus = extractConfig(configs.prometheus); +const log = extractConfig(configs.log); +const tracing = extractConfig(configs.tracing); + +const emailProviderConfig = + email.EMAIL_PROVIDER === 'postmark' + ? ({ + provider: 'postmark' as const, + token: email.EMAIL_PROVIDER_POSTMARK_TOKEN, + messageStream: email.EMAIL_PROVIDER_POSTMARK_MESSAGE_STREAM, + } as const) + : email.EMAIL_PROVIDER === 'smtp' + ? ({ + provider: 'smtp' as const, + protocol: email.EMAIL_PROVIDER_SMTP_PROTOCOL ?? 'smtp', + host: email.EMAIL_PROVIDER_SMTP_HOST, + port: email.EMAIL_PROVIDER_SMTP_PORT, + auth: { + user: email.EMAIL_PROVIDER_SMTP_AUTH_USERNAME, + pass: email.EMAIL_PROVIDER_SMTP_AUTH_PASSWORD, + }, + tls: { + rejectUnauthorized: email.EMAIL_PROVIDER_SMTP_REJECT_UNAUTHORIZED !== '0', + }, + } as const) + : email.EMAIL_PROVIDER === 'sendmail' + ? ({ provider: 'sendmail' } as const) + : ({ provider: 'mock' } as const); + +export type EmailProviderConfig = typeof emailProviderConfig; +export type PostmarkEmailProviderConfig = Extract; +export type SMTPEmailProviderConfig = Extract; +export type SendmailEmailProviderConfig = Extract; +export type MockEmailProviderConfig = Extract; + +export const env = { + environment: base.ENVIRONMENT, + release: base.RELEASE ?? 'local', + http: { + port: base.PORT ?? 6260, + }, + tracing: { + enabled: !!tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT, + collectorEndpoint: tracing.OPENTELEMETRY_COLLECTOR_ENDPOINT, + }, + email: { + provider: emailProviderConfig, + emailFrom: base.EMAIL_FROM, + }, + sentry: sentry.SENTRY === '1' ? { dsn: sentry.SENTRY_DSN } : null, + log: { + level: log.LOG_LEVEL ?? 'info', + }, + prometheus: + prometheus.PROMETHEUS_METRICS === '1' + ? { + labels: { + instance: prometheus.PROMETHEUS_METRICS_LABEL_INSTANCE ?? 'workflows', + }, + port: prometheus.PROMETHEUS_METRICS_PORT ?? 10_254, + } + : null, + postgres: { + connectionString: createConnectionString({ + ssl: postgres.POSTGRES_SSL === '1', + host: postgres.POSTGRES_HOST, + db: postgres.POSTGRES_DB, + password: postgres.POSTGRES_PASSWORD, + port: postgres.POSTGRES_PORT, + user: postgres.POSTGRES_USER, + }), + }, +} as const; diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index 9357983e6d0..1fe0bdc1dc5 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -7,12 +7,10 @@ import { import { createPool } from 'slonik'; import { Logger } from '@graphql-hive/logger'; import { Context } from './context.js'; +import { env } from './environment.js'; +import { createEmailProvider } from './lib/emails/providers.js'; -// TODO: slonik interop -// -const databaseUrl = 'postgresql://postgres:postgres@localhost:5432/registry'; - -const pool = await createPool(databaseUrl); +const pg = await createPool(env.postgres.connectionString); const modules = await Promise.all([ import('./tasks/audit-log-export.js'), @@ -23,12 +21,15 @@ const modules = await Promise.all([ import('./tasks/schema-change-notification.js'), import('./tasks/usage-rate-limit-exceeded.js'), import('./tasks/usage-rate-limit-warning.js'), - import('./workflows/user-onboarding.js'), ]); -const logger = new Logger({ level: 'debug' }); +const logger = new Logger({ level: env.log.level }); -const context: Context = { logger, email: {} }; +const context: Context = { + logger, + email: createEmailProvider(env.email.provider, env.email.emailFrom), + pg, +}; function logLevel(level: GraphileLogLevel) { switch (level) { @@ -48,11 +49,18 @@ function logLevel(level: GraphileLogLevel) { } let runner: Runner = await run({ - logger: new GraphileLogger(scope => (level, message, meta) => { + logger: new GraphileLogger(_scope => (level, message, _meta) => { logger[logLevel(level)](message); }), + // TODO: define cron jobs! crontab: ' ', - connectionString: databaseUrl, + connectionString: env.postgres.connectionString, taskList: Object.fromEntries(modules.map(module => module.task(context))), - +}); + +process.on('SIGINT', () => { + logger.info('Received shutdown signal. Stopping runner.'); + runner.stop().then(() => { + logger.info('Runner shutdown successful.'); + }); }); diff --git a/packages/services/workflows/src/tasks/schema-change-notification.ts b/packages/services/workflows/src/tasks/schema-change-notification.ts index 9dbfbe963db..07c22282541 100644 --- a/packages/services/workflows/src/tasks/schema-change-notification.ts +++ b/packages/services/workflows/src/tasks/schema-change-notification.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { defineTask, implementTask } from '../kit.js'; +import { sendWebhook } from '../lib/webhooks/schema-change-notification.js'; export const SchemaChangeNotificationTask = defineTask({ name: 'schemaChangeNotification', @@ -35,4 +36,6 @@ export const SchemaChangeNotificationTask = defineTask({ }), }); -export const task = implementTask(SchemaChangeNotificationTask, async args => {}); +export const task = implementTask(SchemaChangeNotificationTask, async args => { + await sendWebhook({}); +}); From d54425abba1d3b824ef5dfb74326aafa603d74ca Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 9 Dec 2025 14:04:08 +0100 Subject: [PATCH 05/37] schema purge --- packages/services/api/src/index.ts | 1 - .../api/src/modules/shared/lib/task-runner.ts | 77 ------------------- packages/services/server/src/index.ts | 50 ------------ .../services/workflows/src/environment.ts | 21 +++++ packages/services/workflows/src/index.ts | 10 ++- packages/services/workflows/src/kit.ts | 53 ++++++++----- .../workflows/src/tasks/audit-log-export.ts | 2 +- .../src/tasks/organization-invite.ts | 25 ------ .../src/tasks/purge-expired-schema-checks.ts | 9 ++- .../src/tasks/schema-change-notification.ts | 42 +++------- 10 files changed, 79 insertions(+), 211 deletions(-) delete mode 100644 packages/services/api/src/modules/shared/lib/task-runner.ts delete mode 100644 packages/services/workflows/src/tasks/organization-invite.ts diff --git a/packages/services/api/src/index.ts b/packages/services/api/src/index.ts index e351c4f4fc8..bab370b01eb 100644 --- a/packages/services/api/src/index.ts +++ b/packages/services/api/src/index.ts @@ -18,7 +18,6 @@ export type { OrganizationBilling, OrganizationInvitation, } from './shared/entities'; -export { createTaskRunner } from './modules/shared/lib/task-runner'; export { minifySchema } from './shared/schema'; export { HiveError } from './shared/errors'; export { ProjectType } from './__generated__/types'; diff --git a/packages/services/api/src/modules/shared/lib/task-runner.ts b/packages/services/api/src/modules/shared/lib/task-runner.ts deleted file mode 100644 index 20cd107c0b5..00000000000 --- a/packages/services/api/src/modules/shared/lib/task-runner.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type Logger } from '../providers/logger'; - -/** - * Create task runner that runs a task at at a given interval. - */ -export const createTaskRunner = (args: { - run: () => Promise; - interval: number; - logger: Logger; -}) => { - let task: ReturnType | null = null; - let isStarted = false; - let isStopped = false; - - function loop() { - task = scheduleTask({ - runInMilliSeconds: args.interval, - run: args.run, - logger: args.logger, - name: 'schema-purge', - }); - void task.done.finally(() => { - if (!isStopped) { - loop(); - } - }); - } - - return { - start() { - if (isStarted) { - return; - } - isStarted = true; - loop(); - }, - async stop() { - isStopped = true; - if (task) { - task.cancel(); - await task.done; - } - }, - }; -}; - -const scheduleTask = (args: { - runInMilliSeconds: number; - run: () => Promise; - name: string; - logger: Logger; -}) => { - const runsAt = new Date(Date.now() + args.runInMilliSeconds).toISOString(); - args.logger.info(`Scheduling task "${args.name}" to run at ${runsAt}.`); - let timeout: null | NodeJS.Timeout = setTimeout(async () => { - timeout = null; - args.logger.info(`Running task "${args.name}" to run at ${runsAt}.`); - try { - await args.run(); - } catch (err: unknown) { - args.logger.error(`Failed to run task "${args.name}" to run at ${runsAt}.`, err); - } - args.logger.info(`Completed running task "${args.name}" to run at ${runsAt}.`); - deferred.resolve(); - }, args.runInMilliSeconds); - const deferred = Promise.withResolvers(); - - return { - done: deferred.promise, - cancel: () => { - if (timeout) { - clearTimeout(timeout); - } - deferred.resolve(); - }, - }; -}; diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 609d71b7e0d..323913bebdb 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -16,7 +16,6 @@ import { z } from 'zod'; import formDataPlugin from '@fastify/formbody'; import { createRegistry, - createTaskRunner, CryptoProvider, LogFn, Logger, @@ -39,7 +38,6 @@ import { registerTRPC, reportReadiness, startMetrics, - traceInline, TracingInstance, } from '@hive/service-common'; import { createConnectionString, createStorage as createPostgreSQLStorage } from '@hive/storage'; @@ -209,50 +207,6 @@ export async function main() { }), }) as HivePubSub; - let dbPurgeTaskRunner: null | ReturnType = null; - - if (!env.hiveServices.commerce) { - server.log.debug('Commerce service is disabled. Skip scheduling purge tasks.'); - } else { - server.log.debug( - `Commerce service is enabled. Start scheduling purge tasks every ${env.hiveServices.commerce.dateRetentionPurgeIntervalMinutes} minutes.`, - ); - dbPurgeTaskRunner = createTaskRunner({ - run: traceInline( - 'Purge Task', - { - resultAttributes: result => ({ - 'purge.schema.check.count': result.deletedSchemaCheckCount, - 'purge.sdl.store.count': result.deletedSdlStoreCount, - 'purge.change.approval.count': result.deletedSchemaChangeApprovalCount, - 'purge.contract.approval.count': result.deletedContractSchemaChangeApprovalCount, - }), - }, - async () => { - try { - const result = await storage.purgeExpiredSchemaChecks({ - expiresAt: new Date(), - }); - server.log.debug( - 'Finished running schema check purge task. (deletedSchemaCheckCount=%s deletedSdlStoreCount=%s)', - result.deletedSchemaCheckCount, - result.deletedSdlStoreCount, - ); - - return result; - } catch (error) { - captureException(error); - throw error; - } - }, - ), - interval: env.hiveServices.commerce.dateRetentionPurgeIntervalMinutes * 60 * 1000, - logger: server.log, - }); - - dbPurgeTaskRunner.start(); - } - registerShutdown({ logger: server.log, async onShutdown() { @@ -262,10 +216,6 @@ export async function main() { await server.close(); server.log.info('Stopping Storage handler...'); await storage.destroy(); - if (dbPurgeTaskRunner) { - server.log.info('Stopping expired schema check purge task...'); - await dbPurgeTaskRunner.stop(); - } server.log.info('Shutdown complete.'); }, }); diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index cd9db91b36f..29dff74e979 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -1,6 +1,7 @@ import zod from 'zod'; import { OpenTelemetryConfigurationModel } from '@hive/service-common'; import { createConnectionString } from '@hive/storage'; +import { RequestBroker } from './lib/webhooks/schema-change-notification'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; @@ -81,6 +82,17 @@ const EmailProviderModel = zod.union([ SendmailEmailModel, ]); +const RequestBrokerModel = zod.union([ + zod.object({ + REQUEST_BROKER: emptyString(zod.literal('0').optional()), + }), + zod.object({ + REQUEST_BROKER: zod.literal('1'), + REQUEST_BROKER_ENDPOINT: zod.string().min(1), + REQUEST_BROKER_SIGNATURE: zod.string().min(1), + }), +]); + const PrometheusModel = zod.object({ PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()), @@ -112,6 +124,7 @@ const configs = { prometheus: PrometheusModel.safeParse(process.env), log: LogModel.safeParse(process.env), tracing: OpenTelemetryConfigurationModel.safeParse(process.env), + requestBroker: RequestBrokerModel.safeParse(process.env), }; const environmentErrors: Array = []; @@ -142,6 +155,7 @@ const sentry = extractConfig(configs.sentry); const prometheus = extractConfig(configs.prometheus); const log = extractConfig(configs.log); const tracing = extractConfig(configs.tracing); +const requestBroker = extractConfig(configs.requestBroker); const emailProviderConfig = email.EMAIL_PROVIDER === 'postmark' @@ -211,4 +225,11 @@ export const env = { user: postgres.POSTGRES_USER, }), }, + requestBroker: + requestBroker.REQUEST_BROKER === '1' + ? ({ + endpoint: requestBroker.REQUEST_BROKER_ENDPOINT, + signature: requestBroker.REQUEST_BROKER_SIGNATURE, + } satisfies RequestBroker) + : null, } as const; diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index 1fe0bdc1dc5..39db6925e6d 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -15,9 +15,10 @@ const pg = await createPool(env.postgres.connectionString); const modules = await Promise.all([ import('./tasks/audit-log-export.js'), import('./tasks/email-verification.js'), - import('./tasks/organization-invite.js'), + import('./tasks/organization-invitation.js'), import('./tasks/organization-ownership-transfer.js'), import('./tasks/password-reset.js'), + import('./tasks/purge-expired-schema-checks.js'), import('./tasks/schema-change-notification.js'), import('./tasks/usage-rate-limit-exceeded.js'), import('./tasks/usage-rate-limit-warning.js'), @@ -29,6 +30,7 @@ const context: Context = { logger, email: createEmailProvider(env.email.provider, env.email.emailFrom), pg, + requestBroker: env.requestBroker, }; function logLevel(level: GraphileLogLevel) { @@ -52,8 +54,10 @@ let runner: Runner = await run({ logger: new GraphileLogger(_scope => (level, message, _meta) => { logger[logLevel(level)](message); }), - // TODO: define cron jobs! - crontab: ' ', + crontab: ` + # Purge expired schema checks every Saturday at 10:00AM + 0 10 * * 0 purgeExpiredSchemaChecks + `, connectionString: env.postgres.connectionString, taskList: Object.fromEntries(modules.map(module => module.task(context))), }); diff --git a/packages/services/workflows/src/kit.ts b/packages/services/workflows/src/kit.ts index 7f66920a7f7..5315392c7cb 100644 --- a/packages/services/workflows/src/kit.ts +++ b/packages/services/workflows/src/kit.ts @@ -1,19 +1,13 @@ -import { JobHelpers, Task } from 'graphile-worker'; +import type { JobHelpers, Task } from 'graphile-worker'; import { z } from 'zod'; -import { Logger } from '@graphql-hive/logger'; -import { Context } from './context'; +import type { Logger } from '@graphql-hive/logger'; +import type { Context } from './context'; export type TaskDefinition = { name: TName; schema: z.ZodTypeAny & { _output: TModel }; }; -export function defineTask( - workflow: TaskDefinition, -): TaskDefinition { - return workflow; -} - type TaskImplementationArgs = { input: TPayload; context: Context; @@ -25,31 +19,52 @@ export type TaskImplementation = ( args: TaskImplementationArgs, ) => Promise; +/** + * Define a task + */ +export function defineTask( + workflow: TaskDefinition, +): TaskDefinition { + return workflow; +} + +/** + * Implement a task. + */ export function implementTask( taskDefinition: TaskDefinition, implementation: TaskImplementation, ): (context: Context) => [string, Task] { + const schema = z.object({ + requestId: z.string().optional(), + input: taskDefinition.schema, + }); + return function (context) { return [ taskDefinition.name, function (unsafePayload, helpers) { - const input = taskDefinition.schema.parse(unsafePayload); + const payload = schema.parse(unsafePayload); return implementation({ - input, + input: payload.input, context, helpers, logger: context.logger.child({ - attrs: { - 'job.id': helpers.job.id, - 'job.queueId': helpers.job.job_queue_id, - 'job.attempts': helpers.job.attempts, - 'job.maxAttempts': helpers.job.max_attempts, - 'job.priority': helpers.job.priority, - 'job.taskId': helpers.job.task_id, - }, + 'request.id': payload.requestId, + 'job.id': helpers.job.id, + 'job.queueId': helpers.job.job_queue_id, + 'job.attempts': helpers.job.attempts, + 'job.maxAttempts': helpers.job.max_attempts, + 'job.priority': helpers.job.priority, + 'job.taskId': helpers.job.task_id, }), }); }, ]; }; } + +/** + * Schedule a task. + */ +export function scheduleTask(taskDefinition: TaskDefinition) {} diff --git a/packages/services/workflows/src/tasks/audit-log-export.ts b/packages/services/workflows/src/tasks/audit-log-export.ts index 28038d135e5..c27cadc34d3 100644 --- a/packages/services/workflows/src/tasks/audit-log-export.ts +++ b/packages/services/workflows/src/tasks/audit-log-export.ts @@ -3,7 +3,7 @@ import { defineTask, implementTask } from '../kit.js'; import { renderAuditLogsReportEmail } from '../lib/emails/templates/audit-logs-report.js'; export const AuditLogExportTask = defineTask({ - name: 'audit-log-export', + name: 'auditLogExport', schema: z.object({ organizationId: z.string(), organizationName: z.string(), diff --git a/packages/services/workflows/src/tasks/organization-invite.ts b/packages/services/workflows/src/tasks/organization-invite.ts deleted file mode 100644 index 4c6458183d7..00000000000 --- a/packages/services/workflows/src/tasks/organization-invite.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from 'zod'; -import { defineTask, implementTask } from '../kit.js'; -import { renderOrganizationInvitation } from '../lib/emails/templates/organization-invitation.js'; - -export const OrganizationInvitationTask = defineTask({ - name: 'organizationInvitation', - schema: z.object({ - organizationId: z.string(), - organizationName: z.string(), - code: z.string(), - email: z.string(), - link: z.string(), - }), -}); - -export const task = implementTask(OrganizationInvitationTask, async args => { - await args.context.email.send({ - to: args.input.email, - subject: `You have been invited to join ${args.input.organizationName}`, - body: renderOrganizationInvitation({ - link: args.input.link, - organizationName: args.input.organizationName, - }), - }); -}); diff --git a/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts b/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts index 0221e377f65..b991f0cfcae 100644 --- a/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts +++ b/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts @@ -4,9 +4,14 @@ import { purgeExpiredSchemaChecks } from '../lib/expired-schema-checks'; export const PurgeExpiredSchemaChecks = defineTask({ name: 'purgeExpiredSchemaChecks', - schema: z.undefined(), + schema: z.unknown(), }); export const task = implementTask(PurgeExpiredSchemaChecks, async args => { - await purgeExpiredSchemaChecks({ pool: args.context.pg, expiresAt: new Date() }); + args.logger.debug('purging expired schema checks'); + const statistics = await purgeExpiredSchemaChecks({ + pool: args.context.pg, + expiresAt: new Date(), + }); + args.logger.debug({ statistics }, 'finished purging schema checks'); }); diff --git a/packages/services/workflows/src/tasks/schema-change-notification.ts b/packages/services/workflows/src/tasks/schema-change-notification.ts index 07c22282541..64333fcfd39 100644 --- a/packages/services/workflows/src/tasks/schema-change-notification.ts +++ b/packages/services/workflows/src/tasks/schema-change-notification.ts @@ -1,41 +1,17 @@ -import { z } from 'zod'; import { defineTask, implementTask } from '../kit.js'; -import { sendWebhook } from '../lib/webhooks/schema-change-notification.js'; +import { sendWebhook } from '../lib/webhooks/send-webhook.js'; +import { SchemaChangeNotification } from '../webhooks/schema-change-notification.js'; export const SchemaChangeNotificationTask = defineTask({ name: 'schemaChangeNotification', - schema: z.object({ - endpoint: z.string().nonempty(), - event: z.object({ - organization: z.object({ - id: z.string().nonempty(), - cleanId: z.string().nonempty(), - slug: z.string().nonempty(), - name: z.string().nonempty(), - }), - project: z.object({ - id: z.string().nonempty(), - cleanId: z.string().nonempty(), - slug: z.string().nonempty(), - name: z.string().nonempty(), - }), - target: z.object({ - id: z.string().nonempty(), - cleanId: z.string().nonempty(), - slug: z.string().nonempty(), - name: z.string().nonempty(), - }), - schema: z.object({ - id: z.string().nonempty(), - valid: z.boolean(), - commit: z.string().nonempty(), - }), - changes: z.array(z.any()), - errors: z.array(z.any()), - }), - }), + schema: SchemaChangeNotification, }); export const task = implementTask(SchemaChangeNotificationTask, async args => { - await sendWebhook({}); + await sendWebhook(args.context.logger, args.context.requestBroker, { + attempt: args.helpers.job.attempts, + maxAttempts: args.helpers.job.max_attempts, + data: args.input.event, + endpoint: args.input.endpoint, + }); }); From 5903b86f881ebc0aef0fc72e20e11c27c66fdd5c Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 9 Dec 2025 14:04:48 +0100 Subject: [PATCH 06/37] ooops --- packages/services/workflows/README.md | 15 ++++ .../src/lib/webhooks/send-webhook.ts | 68 +++++++++++++++++++ .../src/tasks/organization-invitation.ts | 25 +++++++ .../webhooks/schema-change-notification.ts | 35 ++++++++++ 4 files changed, 143 insertions(+) create mode 100644 packages/services/workflows/README.md create mode 100644 packages/services/workflows/src/lib/webhooks/send-webhook.ts create mode 100644 packages/services/workflows/src/tasks/organization-invitation.ts create mode 100644 packages/services/workflows/src/webhooks/schema-change-notification.ts diff --git a/packages/services/workflows/README.md b/packages/services/workflows/README.md new file mode 100644 index 00000000000..07bb6274dae --- /dev/null +++ b/packages/services/workflows/README.md @@ -0,0 +1,15 @@ +# Workflow Service + +Services for running asynchronous tasks and cron jobs. E.g. sending email, webhooks or other +maintenance/clen up tasks. + +## Structure + +``` +# Definition of Webook Payload Models using zod +src/webhooks/* +# Task Definitions +src/tasks/* +# General lib +src/lib/* +``` diff --git a/packages/services/workflows/src/lib/webhooks/send-webhook.ts b/packages/services/workflows/src/lib/webhooks/send-webhook.ts new file mode 100644 index 00000000000..e4634eda8f3 --- /dev/null +++ b/packages/services/workflows/src/lib/webhooks/send-webhook.ts @@ -0,0 +1,68 @@ +import got from 'got'; +import type { Logger } from '@graphql-hive/logger'; + +export type RequestBroker = { + endpoint: string; + signature: string; +}; + +export async function sendWebhook( + logger: Logger, + requestBroker: RequestBroker | null, + args: { + attempt: number; + maxAttempts: number; + /** endpoint to be called */ + endpoint: string; + /** JSON data to be sent to the endpoint */ + data: unknown; + }, +) { + if (args.attempt < args.maxAttempts) { + logger.debug('Calling webhook'); + + try { + if (!requestBroker) { + await got.post(args.endpoint, { + headers: { + Accept: 'application/json', + 'Accept-Encoding': 'gzip, deflate, br', + 'Content-Type': 'application/json', + }, + timeout: { + request: 10_000, + }, + json: args.data, + }); + return; + } + + await got.post(requestBroker.endpoint, { + headers: { + Accept: 'text/plain', + 'x-hive-signature': requestBroker.signature, + }, + timeout: { + request: 10_000, + }, + json: { + url: args.endpoint, + method: 'POST', + headers: { + Accept: 'application/json', + 'Accept-Encoding': 'gzip, deflate, br', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(args.data), + resolveResponseBody: false, + }, + }); + } catch (error) { + logger.error('Failed to call webhook.'); + // so we can re-try + throw error; + } + } else { + logger.warn('Giving up on webhook.'); + } +} diff --git a/packages/services/workflows/src/tasks/organization-invitation.ts b/packages/services/workflows/src/tasks/organization-invitation.ts new file mode 100644 index 00000000000..4c6458183d7 --- /dev/null +++ b/packages/services/workflows/src/tasks/organization-invitation.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; +import { renderOrganizationInvitation } from '../lib/emails/templates/organization-invitation.js'; + +export const OrganizationInvitationTask = defineTask({ + name: 'organizationInvitation', + schema: z.object({ + organizationId: z.string(), + organizationName: z.string(), + code: z.string(), + email: z.string(), + link: z.string(), + }), +}); + +export const task = implementTask(OrganizationInvitationTask, async args => { + await args.context.email.send({ + to: args.input.email, + subject: `You have been invited to join ${args.input.organizationName}`, + body: renderOrganizationInvitation({ + link: args.input.link, + organizationName: args.input.organizationName, + }), + }); +}); diff --git a/packages/services/workflows/src/webhooks/schema-change-notification.ts b/packages/services/workflows/src/webhooks/schema-change-notification.ts new file mode 100644 index 00000000000..f8e32a86766 --- /dev/null +++ b/packages/services/workflows/src/webhooks/schema-change-notification.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +/** + * Webhook payload definition for schema change notifications. + */ +export const SchemaChangeNotification = z.object({ + endpoint: z.string().nonempty(), + event: z.object({ + organization: z.object({ + id: z.string().nonempty(), + cleanId: z.string().nonempty(), + slug: z.string().nonempty(), + name: z.string().nonempty(), + }), + project: z.object({ + id: z.string().nonempty(), + cleanId: z.string().nonempty(), + slug: z.string().nonempty(), + name: z.string().nonempty(), + }), + target: z.object({ + id: z.string().nonempty(), + cleanId: z.string().nonempty(), + slug: z.string().nonempty(), + name: z.string().nonempty(), + }), + schema: z.object({ + id: z.string().nonempty(), + valid: z.boolean(), + commit: z.string().nonempty(), + }), + changes: z.array(z.any()), + errors: z.array(z.any()), + }), +}); From 72c701317d63cb2a31b78e7c7e50b173daab3d12 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 9 Dec 2025 15:00:43 +0100 Subject: [PATCH 07/37] use new tasks --- packages/services/api/src/create.ts | 19 ++++---- .../alerts/providers/adapters/webhook.ts | 43 ++++------------- .../providers/audit-logs-manager.ts | 8 ++-- .../providers/organization-manager.ts | 10 ++-- .../src/modules/shared/providers/emails.ts | 23 --------- packages/services/commerce/.env.template | 1 - packages/services/commerce/src/index.ts | 9 ++-- .../commerce/src/rate-limit/emails.ts | 30 +++--------- .../commerce/src/rate-limit/limiter.ts | 7 ++- packages/services/server/src/index.ts | 4 ++ packages/services/server/src/supertokens.ts | 17 +++---- packages/services/workflows/src/kit.ts | 33 +++++++++++-- patches/slonik@30.4.4.patch | 48 +++++++++++++++++++ pnpm-lock.yaml | 46 +++++++++--------- tsconfig.json | 2 + 15 files changed, 156 insertions(+), 144 deletions(-) delete mode 100644 packages/services/api/src/modules/shared/providers/emails.ts diff --git a/packages/services/api/src/create.ts b/packages/services/api/src/create.ts index ab80f1fe955..b8405d3a3c4 100644 --- a/packages/services/api/src/create.ts +++ b/packages/services/api/src/create.ts @@ -1,5 +1,6 @@ import { CONTEXT, createApplication, Provider, Scope } from 'graphql-modules'; import { Redis } from 'ioredis'; +import { TaskScheduler } from '@hive/workflows/kit'; import { adminModule } from './modules/admin'; import { alertsModule } from './modules/alerts'; import { WEBHOOKS_CONFIG, WebhooksConfig } from './modules/alerts/providers/tokens'; @@ -47,7 +48,6 @@ import { import { sharedModule } from './modules/shared'; import { CryptoProvider, encryptionSecretProvider } from './modules/shared/providers/crypto'; import { DistributedCache } from './modules/shared/providers/distributed-cache'; -import { Emails, EMAILS_ENDPOINT } from './modules/shared/providers/emails'; import { HttpClient } from './modules/shared/providers/http-client'; import { IdTranslator } from './modules/shared/providers/id-translator'; import { @@ -116,6 +116,7 @@ export function createRegistry({ appDeploymentsEnabled, otelTracingEnabled, prometheus, + taskScheduler, }: { logger: Logger; storage: Storage; @@ -161,6 +162,7 @@ export function createRegistry({ appDeploymentsEnabled: boolean; otelTracingEnabled: boolean; prometheus: null | Record; + taskScheduler: TaskScheduler; }) { const s3Config: S3Config = [ { @@ -210,7 +212,6 @@ export function createRegistry({ Mutex, DistributedCache, CryptoProvider, - Emails, InMemoryRateLimitStore, InMemoryRateLimiter, { @@ -320,16 +321,12 @@ export function createRegistry({ return new PrometheusConfig(!!prometheus); }, }, - ]; - - if (emailsEndpoint) { - providers.push({ - provide: EMAILS_ENDPOINT, - useValue: emailsEndpoint, + { + provide: TaskScheduler, + useValue: taskScheduler, scope: Scope.Singleton, - }); - modules.push(supportModule); - } + }, + ]; if (supportConfig) { providers.push(provideSupportConfig(supportConfig)); diff --git a/packages/services/api/src/modules/alerts/providers/adapters/webhook.ts b/packages/services/api/src/modules/alerts/providers/adapters/webhook.ts index c56a90e7e49..e3d83b299fb 100644 --- a/packages/services/api/src/modules/alerts/providers/adapters/webhook.ts +++ b/packages/services/api/src/modules/alerts/providers/adapters/webhook.ts @@ -1,10 +1,7 @@ -import { CONTEXT, Inject, Injectable, Scope } from 'graphql-modules'; -import type { WebhooksApi } from '@hive/webhooks'; -import { createTRPCProxyClient, httpLink } from '@trpc/client'; -import { HttpClient } from '../../../shared/providers/http-client'; +import { Injectable, Scope } from 'graphql-modules'; +import { TaskScheduler } from '@hive/workflows/kit'; +import { SchemaChangeNotificationTask } from '@hive/workflows/tasks/schema-change-notification'; import { Logger } from '../../../shared/providers/logger'; -import type { WebhooksConfig } from '../tokens'; -import { WEBHOOKS_CONFIG } from '../tokens'; import type { CommunicationAdapter, SchemaChangeNotificationInput } from './common'; @Injectable({ @@ -12,26 +9,12 @@ import type { CommunicationAdapter, SchemaChangeNotificationInput } from './comm }) export class WebhookCommunicationAdapter implements CommunicationAdapter { private logger: Logger; - private webhooksService; constructor( logger: Logger, - private http: HttpClient, - @Inject(WEBHOOKS_CONFIG) private config: WebhooksConfig, - @Inject(CONTEXT) context: GraphQLModules.ModuleContext, + private taskScheduler: TaskScheduler, ) { this.logger = logger.child({ service: 'WebhookCommunicationAdapter' }); - this.webhooksService = createTRPCProxyClient({ - links: [ - httpLink({ - url: `${config.endpoint}/trpc`, - fetch, - headers: { - 'x-request-id': context.requestId, - }, - }), - ], - }); } async sendSchemaChangeNotification(input: SchemaChangeNotificationInput) { @@ -41,20 +24,10 @@ export class WebhookCommunicationAdapter implements CommunicationAdapter { input.event.project.id, input.event.target.id, ); - try { - await this.webhooksService.schedule.mutate({ - endpoint: input.channel.webhookEndpoint!, - event: input.event, - }); - } catch (error) { - const errorText = - error instanceof Error - ? error.toString() - : typeof error === 'string' - ? error - : JSON.stringify(error); - this.logger.error(`Failed to send Webhook notification`, errorText); - } + await this.taskScheduler.scheduleTask(SchemaChangeNotificationTask, { + endpoint: input.channel.webhookEndpoint!, + event: input.event, + }); } async sendChannelConfirmation() { diff --git a/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.ts b/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.ts index 322ae3fea0a..65c45476452 100644 --- a/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.ts +++ b/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.ts @@ -2,11 +2,12 @@ import { stringify } from 'csv-stringify'; import { endOfDay, startOfDay } from 'date-fns'; import { Injectable, Scope } from 'graphql-modules'; import { traceFn } from '@hive/service-common'; +import { TaskScheduler } from '@hive/workflows/kit'; +import { AuditLogExportTask } from '@hive/workflows/tasks/audit-log-export'; import { captureException } from '@sentry/node'; import { Session } from '../../auth/lib/authz'; import { type AwsClient } from '../../cdn/providers/aws'; import { ClickHouse, sql } from '../../operations/providers/clickhouse-client'; -import { Emails } from '../../shared/providers/emails'; import { Logger } from '../../shared/providers/logger'; import { Storage } from '../../shared/providers/storage'; import { formatToClickhouseDateTime } from './audit-log-recorder'; @@ -33,7 +34,7 @@ export class AuditLogManager { logger: Logger, private clickHouse: ClickHouse, private s3Config: AuditLogS3Config, - private emailProvider: Emails, + private taskScheduler: TaskScheduler, private session: Session, private storage: Storage, ) { @@ -212,7 +213,8 @@ export class AuditLogManager { const organization = await this.storage.getOrganization({ organizationId, }); - await this.emailProvider.api?.sendAuditLogsReportEmail.mutate({ + + await this.taskScheduler.scheduleTask(AuditLogExportTask, { email: email, organizationName: organization.name, organizationId: organization.id, diff --git a/packages/services/api/src/modules/organization/providers/organization-manager.ts b/packages/services/api/src/modules/organization/providers/organization-manager.ts index a252a46c738..f9188c6bce0 100644 --- a/packages/services/api/src/modules/organization/providers/organization-manager.ts +++ b/packages/services/api/src/modules/organization/providers/organization-manager.ts @@ -1,6 +1,9 @@ import { Inject, Injectable, Scope } from 'graphql-modules'; import { OrganizationReferenceInput } from 'packages/libraries/core/src/client/__generated__/types'; import { z } from 'zod'; +import { TaskScheduler } from '@hive/workflows/kit'; +import { OrganizationInvitationTask } from '@hive/workflows/tasks/organization-invitation'; +import { OrganizationOwnershipTransferTask } from '@hive/workflows/tasks/organization-ownership-transfer'; import * as GraphQLSchema from '../../../__generated__/types'; import { Organization } from '../../../shared/entities'; import { HiveError } from '../../../shared/errors'; @@ -9,7 +12,6 @@ import { Session } from '../../auth/lib/authz'; import { AuthManager } from '../../auth/providers/auth-manager'; import { BillingProvider } from '../../commerce/providers/billing.provider'; import { OIDCIntegrationsProvider } from '../../oidc-integrations/providers/oidc-integrations.provider'; -import { Emails } from '../../shared/providers/emails'; import { IdTranslator } from '../../shared/providers/id-translator'; import { InMemoryRateLimiter } from '../../shared/providers/in-memory-rate-limiter'; import { Logger } from '../../shared/providers/logger'; @@ -43,7 +45,7 @@ export class OrganizationManager { private tokenStorage: TokenStorage, private billingProvider: BillingProvider, private oidcIntegrationProvider: OIDCIntegrationsProvider, - private emails: Emails, + private taskScheduler: TaskScheduler, private organizationMemberRoles: OrganizationMemberRoles, private organizationMembers: OrganizationMembers, private resourceAssignments: ResourceAssignments, @@ -626,7 +628,7 @@ export class OrganizationManager { step: 'invitingMembers', }), // schedule an email - this.emails.api?.sendOrganizationInviteEmail.mutate({ + this.taskScheduler.scheduleTask(OrganizationInvitationTask, { organizationId: invitation.organizationId, organizationName: organization.name, email, @@ -745,7 +747,7 @@ export class OrganizationManager { userId: member.user.id, }); - await this.emails.api?.sendOrganizationOwnershipTransferEmail.mutate({ + await this.taskScheduler.scheduleTask(OrganizationOwnershipTransferTask, { email: member.user.email, organizationId: organization.id, organizationName: organization.name, diff --git a/packages/services/api/src/modules/shared/providers/emails.ts b/packages/services/api/src/modules/shared/providers/emails.ts deleted file mode 100644 index 5df8c90984e..00000000000 --- a/packages/services/api/src/modules/shared/providers/emails.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Inject, Injectable, InjectionToken, Optional } from 'graphql-modules'; -import type { EmailsApi } from '@hive/emails'; -import { createTRPCProxyClient, httpLink } from '@trpc/client'; - -export const EMAILS_ENDPOINT = new InjectionToken('EMAILS_ENDPOINT'); - -@Injectable() -export class Emails { - public api; - - constructor(@Optional() @Inject(EMAILS_ENDPOINT) endpoint?: string) { - this.api = endpoint - ? createTRPCProxyClient({ - links: [ - httpLink({ - url: `${endpoint}/trpc`, - fetch, - }), - ], - }) - : null; - } -} diff --git a/packages/services/commerce/.env.template b/packages/services/commerce/.env.template index 87575924792..f2adee44d72 100644 --- a/packages/services/commerce/.env.template +++ b/packages/services/commerce/.env.template @@ -10,6 +10,5 @@ POSTGRES_PASSWORD=postgres POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=registry -EMAILS_ENDPOINT="http://localhost:6260" WEB_APP_URL="http://localhost:3000" STRIPE_SECRET_KEY="empty" diff --git a/packages/services/commerce/src/index.ts b/packages/services/commerce/src/index.ts index 614fa66f07e..6a323506fc2 100644 --- a/packages/services/commerce/src/index.ts +++ b/packages/services/commerce/src/index.ts @@ -11,6 +11,7 @@ import { TracingInstance, } from '@hive/service-common'; import { createConnectionString, createStorage as createPostgreSQLStorage } from '@hive/storage'; +import { TaskScheduler } from '@hive/workflows/kit'; import * as Sentry from '@sentry/node'; import { commerceRouter } from './api'; import { env } from './environment'; @@ -59,6 +60,8 @@ async function main() { tracing ? [tracing.instrumentSlonik()] : undefined, ); + const taskScheduler = new TaskScheduler(postgres.pool.pool); + const usageEstimator = createEstimator({ logger: server.log, clickhouse: { @@ -76,11 +79,7 @@ async function main() { interval: env.rateLimit.limitCacheUpdateIntervalMs, }, usageEstimator, - emails: env.hiveServices.emails.endpoint - ? { - endpoint: env.hiveServices.emails.endpoint, - } - : undefined, + taskScheduler, storage: postgres, }); diff --git a/packages/services/commerce/src/rate-limit/emails.ts b/packages/services/commerce/src/rate-limit/emails.ts index 013fde6bad1..b71766e2494 100644 --- a/packages/services/commerce/src/rate-limit/emails.ts +++ b/packages/services/commerce/src/rate-limit/emails.ts @@ -1,19 +1,9 @@ -import type { EmailsApi } from '@hive/emails'; -import { createTRPCProxyClient, httpLink } from '@trpc/client'; +import type { TaskScheduler } from '@hive/workflows/kit'; +import { UsageRateLimitExceededTask } from '@hive/workflows/tasks/usage-rate-limit-exceeded'; +import { UsageRateLimitWarningTask } from '@hive/workflows/tasks/usage-rate-limit-warning'; import { env } from '../environment'; -export function createEmailScheduler(config?: { endpoint: string }) { - const api = config?.endpoint - ? createTRPCProxyClient({ - links: [ - httpLink({ - url: `${config.endpoint}/trpc`, - fetch, - }), - ], - }) - : null; - +export function createEmailScheduler(taskScheduler: TaskScheduler) { let scheduledEmails: Promise[] = []; return { @@ -38,12 +28,8 @@ export function createEmailScheduler(config?: { endpoint: string }) { current: number; }; }) { - if (!api) { - return scheduledEmails.push(Promise.resolve()); - } - return scheduledEmails.push( - api.sendRateLimitExceededEmail.mutate({ + taskScheduler.scheduleTask(UsageRateLimitExceededTask, { email: input.organization.email, organizationId: input.organization.id, organizationName: input.organization.name, @@ -74,12 +60,8 @@ export function createEmailScheduler(config?: { endpoint: string }) { current: number; }; }) { - if (!api) { - return scheduledEmails.push(Promise.resolve()); - } - return scheduledEmails.push( - api.sendRateLimitWarningEmail.mutate({ + taskScheduler.scheduleTask(UsageRateLimitWarningTask, { email: input.organization.email, organizationId: input.organization.id, organizationName: input.organization.name, diff --git a/packages/services/commerce/src/rate-limit/limiter.ts b/packages/services/commerce/src/rate-limit/limiter.ts index 5eabd51b324..01830750a23 100644 --- a/packages/services/commerce/src/rate-limit/limiter.ts +++ b/packages/services/commerce/src/rate-limit/limiter.ts @@ -2,6 +2,7 @@ import { endOfMonth, startOfMonth } from 'date-fns'; import type { Storage } from '@hive/api'; import type { ServiceLogger } from '@hive/service-common'; import { traceInline } from '@hive/service-common'; +import { TaskScheduler } from '@hive/workflows/kit'; import * as Sentry from '@sentry/node'; import { UsageEstimator } from '../usage-estimator/estimator'; import { createEmailScheduler } from './emails'; @@ -52,13 +53,11 @@ export function createRateLimiter(config: { rateLimitConfig: { interval: number; }; - emails?: { - endpoint: string; - }; + taskScheduler: TaskScheduler; usageEstimator: UsageEstimator; storage: Storage; }) { - const emails = createEmailScheduler(config.emails); + const emails = createEmailScheduler(config.taskScheduler); const { logger, usageEstimator, storage } = config; let intervalHandle: ReturnType | null = null; diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 323913bebdb..c54987f9dbd 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -41,6 +41,7 @@ import { TracingInstance, } from '@hive/service-common'; import { createConnectionString, createStorage as createPostgreSQLStorage } from '@hive/storage'; +import { TaskScheduler } from '@hive/workflows/kit'; import { contextLinesIntegration, dedupeIntegration, @@ -193,6 +194,7 @@ export async function main() { 10, tracing ? [tracing.instrumentSlonik()] : [], ); + const taskScheduler = new TaskScheduler(storage.pool.pool); const redis = createRedisClient('Redis', env.redis, server.log.child({ source: 'Redis' })); @@ -374,6 +376,7 @@ export async function main() { appDeploymentsEnabled: env.featureFlags.appDeploymentsEnabled, otelTracingEnabled: env.featureFlags.otelTracingEnabled, prometheus: env.prometheus, + taskScheduler, }); const organizationAccessTokenStrategy = (logger: Logger) => @@ -468,6 +471,7 @@ export async function main() { crypto, logger: server.log, redis, + taskScheduler, broadcastLog(id, message) { pubSub.publish('oidcIntegrationLogs', id, { timestamp: new Date().toISOString(), diff --git a/packages/services/server/src/supertokens.ts b/packages/services/server/src/supertokens.ts index 55b382786e2..d33ce21291a 100644 --- a/packages/services/server/src/supertokens.ts +++ b/packages/services/server/src/supertokens.ts @@ -11,8 +11,9 @@ import type { TypeInput as ThirdPartEmailPasswordTypeInput } from 'supertokens-n import type { TypeInput } from 'supertokens-node/types'; import zod from 'zod'; import { type Storage } from '@hive/api'; -import type { EmailsApi } from '@hive/emails'; -import { createTRPCProxyClient, httpLink } from '@trpc/client'; +import { TaskScheduler } from '@hive/workflows/kit'; +import { EmailVerificationTask } from '@hive/workflows/tasks/email-verification'; +import { PasswordResetTask } from '@hive/workflows/tasks/password-reset'; import { createInternalApiCaller } from './api'; import { env } from './environment'; import { @@ -37,11 +38,10 @@ export const backendConfig = (requirements: { logger: FastifyBaseLogger; broadcastLog: BroadcastOIDCIntegrationLog; redis: Redis; + taskScheduler: TaskScheduler; }): TypeInput => { const { logger } = requirements; - const emailsService = createTRPCProxyClient({ - links: [httpLink({ url: `${env.hiveServices.emails?.endpoint}/trpc` })], - }); + const internalApi = createInternalApiCaller({ storage: requirements.storage, crypto: requirements.crypto, @@ -134,13 +134,14 @@ export const backendConfig = (requirements: { ...originalImplementation, async sendEmail(input) { if (input.type === 'PASSWORD_RESET') { - await emailsService.sendPasswordResetEmail.mutate({ + await requirements.taskScheduler.scheduleTask(PasswordResetTask, { user: { id: input.user.id, email: input.user.email, }, passwordResetLink: input.passwordResetLink, }); + return Promise.resolve(); } @@ -160,14 +161,13 @@ export const backendConfig = (requirements: { ...originalImplementation, async sendEmail(input) { if (input.type === 'EMAIL_VERIFICATION') { - await emailsService.sendEmailVerificationEmail.mutate({ + await requirements.taskScheduler.scheduleTask(EmailVerificationTask, { user: { id: input.user.id, email: input.user.email, }, emailVerifyLink: input.emailVerifyLink, }); - return Promise.resolve(); } }, @@ -414,6 +414,7 @@ export function initSupertokens(requirements: { logger: FastifyBaseLogger; broadcastLog: BroadcastOIDCIntegrationLog; redis: Redis; + taskScheduler: TaskScheduler; }) { supertokens.init(backendConfig(requirements)); } diff --git a/packages/services/workflows/src/kit.ts b/packages/services/workflows/src/kit.ts index 5315392c7cb..2f47de61c79 100644 --- a/packages/services/workflows/src/kit.ts +++ b/packages/services/workflows/src/kit.ts @@ -1,4 +1,5 @@ -import type { JobHelpers, Task } from 'graphile-worker'; +import { makeWorkerUtils, WorkerUtils, type JobHelpers, type Task } from 'graphile-worker'; +import type { Pool } from 'pg'; import { z } from 'zod'; import type { Logger } from '@graphql-hive/logger'; import type { Context } from './context'; @@ -65,6 +66,32 @@ export function implementTask( } /** - * Schedule a task. + * Schedule a tasks. */ -export function scheduleTask(taskDefinition: TaskDefinition) {} +export class TaskScheduler { + tools: Promise; + constructor(pgPool: Pool) { + this.tools = makeWorkerUtils({ + pgPool, + }); + } + + async scheduleTask( + taskDefinition: TaskDefinition, + payload: TPayload, + opts?: { + requestId?: string; + }, + ) { + await ( + await this.tools + ).addJob(taskDefinition.name, { + requestId: opts?.requestId, + input: taskDefinition.schema.parse(payload), + }); + } + + async dispose() { + await (await this.tools).release(); + } +} diff --git a/patches/slonik@30.4.4.patch b/patches/slonik@30.4.4.patch index 35cc74dfffa..472e2b3263f 100644 --- a/patches/slonik@30.4.4.patch +++ b/patches/slonik@30.4.4.patch @@ -1,3 +1,15 @@ +diff --git a/dist/src/binders/bindPool.js b/dist/src/binders/bindPool.js +index ad509058bf5d26c82d4b2aea35e945df2f83f38e..1ca27403f70362f9a60abef7df6c2fdaaba1805b 100644 +--- a/dist/src/binders/bindPool.js ++++ b/dist/src/binders/bindPool.js +@@ -7,6 +7,7 @@ const factories_1 = require("../factories"); + const state_1 = require("../state"); + const bindPool = (parentLog, pool, clientConfiguration) => { + return { ++ pool, + any: (query) => { + return (0, factories_1.createConnection)(parentLog, pool, clientConfiguration, 'IMPLICIT_QUERY', (connectionLog, connection, boundConnection) => { + return boundConnection.any(query); diff --git a/dist/src/factories/createPool.js b/dist/src/factories/createPool.js index b91a9fe433dc340f5cdf096ca4c568297c343ab3..401df1272d1c7f344bb956b38cc7dbde29231742 100644 --- a/dist/src/factories/createPool.js @@ -19,3 +31,39 @@ index b91a9fe433dc340f5cdf096ca4c568297c343ab3..401df1272d1c7f344bb956b38cc7dbde state_1.poolStateMap.set(pool, { ended: false, mock: false, +diff --git a/dist/src/types.d.ts b/dist/src/types.d.ts +index d091b301b1df0f8d9ad9298d587081fe6d33c0be..57ea5a46fbf0878546e34debed2401efcb66d7fb 100644 +--- a/dist/src/types.d.ts ++++ b/dist/src/types.d.ts +@@ -138,6 +138,7 @@ export declare type DatabasePool = CommonQueryMethods & { + readonly end: () => Promise; + readonly getPoolState: () => PoolState; + readonly stream: StreamFunction; ++ readonly pool: PgPool; + }; + export declare type DatabaseConnection = DatabasePool | DatabasePoolConnection; + export declare type QueryResultRowColumn = PrimitiveValueExpression; +diff --git a/src/binders/bindPool.ts b/src/binders/bindPool.ts +index d10bb50117b613f262ee715fc40745e8270a60b3..fde977dd042ec2561163f252c7f76f92cb043eb0 100644 +--- a/src/binders/bindPool.ts ++++ b/src/binders/bindPool.ts +@@ -26,6 +26,7 @@ export const bindPool = ( + clientConfiguration: ClientConfiguration, + ): DatabasePool => { + return { ++ pool, + any: (query: TaggedTemplateLiteralInvocation) => { + return createConnection( + parentLog, +diff --git a/src/types.ts b/src/types.ts +index da293a0a4ce2583c43711cbe90d4829ec9c46fa8..962acdd5c2652e6e61147adc03204bca065cd28c 100644 +--- a/src/types.ts ++++ b/src/types.ts +@@ -191,6 +191,7 @@ export type DatabasePool = CommonQueryMethods & { + readonly end: () => Promise, + readonly getPoolState: () => PoolState, + readonly stream: StreamFunction, ++ readonly pool: PgPool, + }; + + export type DatabaseConnection = DatabasePool | DatabasePoolConnection; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b561f48ae11..03f43a61ad9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,7 +74,7 @@ patchedDependencies: hash: 195ae63d47810ca4c987421948f15869356e598dccde9e8c9a4ff4efd3e88322 path: patches/p-cancelable@4.0.1.patch slonik@30.4.4: - hash: 408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299 + hash: 195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9 path: patches/slonik@30.4.4.patch importers: @@ -375,7 +375,7 @@ importers: version: 5.8.2 slonik: specifier: 30.4.4 - version: 30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299) + version: 30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9) strip-ansi: specifier: 7.1.2 version: 7.1.2 @@ -711,7 +711,7 @@ importers: version: 11.10.2(pg-query-stream@4.7.0(pg@8.13.1)) slonik: specifier: 30.4.4 - version: 30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299) + version: 30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9) ts-node: specifier: 10.9.2 version: 10.9.2(@swc/core@1.13.5)(@types/node@22.10.5)(typescript@5.7.3) @@ -901,7 +901,7 @@ importers: version: 5.0.0-beta.2 slonik: specifier: 30.4.4 - version: 30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299) + version: 30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9) stripe: specifier: 17.5.0 version: 17.5.0 @@ -1452,7 +1452,7 @@ importers: version: 15.1.3 slonik: specifier: 30.4.4 - version: 30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299) + version: 30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9) zod: specifier: 3.25.76 version: 3.25.76 @@ -1497,13 +1497,13 @@ importers: version: 11.10.2(pg-query-stream@4.7.0(pg@8.13.1)) slonik: specifier: 30.4.4 - version: 30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299) + version: 30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9) slonik-interceptor-query-logging: specifier: 46.4.0 - version: 46.4.0(slonik@30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299)) + version: 46.4.0(slonik@30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9)) slonik-utilities: specifier: 1.9.4 - version: 1.9.4(slonik@30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299)) + version: 1.9.4(slonik@30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9)) tslib: specifier: 2.8.1 version: 2.8.1 @@ -19836,8 +19836,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19989,11 +19989,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': + '@aws-sdk/client-sso-oidc@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20032,7 +20032,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -20252,11 +20251,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0': + '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20295,6 +20294,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20526,7 +20526,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20773,7 +20773,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -21162,7 +21162,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 @@ -40992,14 +40992,14 @@ snapshots: slick@1.12.2: {} - slonik-interceptor-query-logging@46.4.0(slonik@30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299)): + slonik-interceptor-query-logging@46.4.0(slonik@30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9)): dependencies: crack-json: 1.3.0 pretty-ms: 7.0.1 serialize-error: 8.1.0 - slonik: 30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299) + slonik: 30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9) - slonik-utilities@1.9.4(slonik@30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299)): + slonik-utilities@1.9.4(slonik@30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9)): dependencies: core-js: 3.25.5 delay: 4.4.1 @@ -41007,9 +41007,9 @@ snapshots: lodash: 4.17.21 roarr: 7.14.3 serialize-error: 5.0.0 - slonik: 30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299) + slonik: 30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9) - slonik@30.4.4(patch_hash=408d2a91c53799f60fa2e59860bc29067d20318cbf5844306888d0098b88d299): + slonik@30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9): dependencies: concat-stream: 2.0.0 es6-error: 4.1.1 diff --git a/tsconfig.json b/tsconfig.json index c9fa80ea49c..9f35a66e39c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -66,6 +66,8 @@ "@hive/usage-ingestor/src/normalize-operation": [ "./packages/services/usage-ingestor/src/normalize-operation.ts" ], + "@hive/workflows/kit": ["./packages/services/workflows/src/kit.ts"], + "@hive/workflows/tasks/*": ["./packages/services/workflows/src/tasks/*"], "@/*": ["./packages/web/app/src/*"], "testkit/*": ["./integration-tests/testkit/*"] } From d3b60e50563bd6fda49c169a630e2ecfb65e8837 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 9 Dec 2025 15:01:31 +0100 Subject: [PATCH 08/37] ? --- .zed/settings.json | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 .zed/settings.json diff --git a/.zed/settings.json b/.zed/settings.json deleted file mode 100644 index a7b2dd384ad..00000000000 --- a/.zed/settings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "languages": { - "Markdown": { - "format_on_save": "on", - "formatter": { - "external": { - "command": "./node_modules/.bin/prettier", - "arguments": ["--stdin-filepath", "{buffer_path}", "--ignore-unknown"] - } - } - }, - "JavaScript": { - "format_on_save": "on", - "formatter": { - "external": { - "command": "./node_modules/.bin/prettier", - "arguments": ["--stdin-filepath", "{buffer_path}", "--ignore-unknown"] - } - } - } - } -} From 8a6cc388cc5de2b2c0cfd46e96300d5f3fe8a343 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 9 Dec 2025 15:03:51 +0100 Subject: [PATCH 09/37] remove comment --- packages/services/workflows/src/tasks/audit-log-export.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/services/workflows/src/tasks/audit-log-export.ts b/packages/services/workflows/src/tasks/audit-log-export.ts index c27cadc34d3..0aa4f4a4a53 100644 --- a/packages/services/workflows/src/tasks/audit-log-export.ts +++ b/packages/services/workflows/src/tasks/audit-log-export.ts @@ -15,7 +15,6 @@ export const AuditLogExportTask = defineTask({ }); export const task = implementTask(AuditLogExportTask, async args => { - // TODO: export audit log and store it await args.context.email.send({ to: args.input.email, subject: 'Hive - Audit Log Report', From 030faab4db3b114ff5dc30dfea7264be94938729 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 9 Dec 2025 15:05:43 +0100 Subject: [PATCH 10/37] fallback to info --- packages/services/workflows/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index 39db6925e6d..b9e9f1d7de0 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -47,7 +47,8 @@ function logLevel(level: GraphileLogLevel) { return 'error' as const; } } - throw new Error('nooop'); + + return 'info'; } let runner: Runner = await run({ From df0044d1eccf9b805c71d77e81a16227eb10f853 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 9 Dec 2025 15:06:26 +0100 Subject: [PATCH 11/37] reuse pool --- packages/services/workflows/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index b9e9f1d7de0..f0e7cf6d78c 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -59,7 +59,7 @@ let runner: Runner = await run({ # Purge expired schema checks every Saturday at 10:00AM 0 10 * * 0 purgeExpiredSchemaChecks `, - connectionString: env.postgres.connectionString, + pgPool: pg.pool, taskList: Object.fromEntries(modules.map(module => module.task(context))), }); From 459727ebdc4575166c333542b346dc7f6fc513b1 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 9 Dec 2025 15:19:41 +0100 Subject: [PATCH 12/37] fix: paths --- packages/services/workflows/src/context.ts | 4 ++-- .../services/workflows/src/environment.ts | 2 +- packages/services/workflows/src/index.ts | 19 +++++++++++++------ .../src/lib/expired-schema-checks.ts | 16 ++++++++-------- .../workflows/src/tasks/password-reset.ts | 2 +- .../src/tasks/purge-expired-schema-checks.ts | 2 +- .../src/tasks/usage-rate-limit-exceeded.ts | 2 +- .../src/tasks/usage-rate-limit-warning.ts | 2 +- 8 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/services/workflows/src/context.ts b/packages/services/workflows/src/context.ts index b7bd4fb9e4d..884702dbf0d 100644 --- a/packages/services/workflows/src/context.ts +++ b/packages/services/workflows/src/context.ts @@ -1,7 +1,7 @@ import type { DatabasePool } from 'slonik'; import type { Logger } from '@graphql-hive/logger'; -import type { EmailProvider } from './lib/emails/providers'; -import { RequestBroker } from './lib/webhooks/schema-change-notification'; +import type { EmailProvider } from './lib/emails/providers.js'; +import { RequestBroker } from './lib/webhooks/send-webhook.js'; export type Context = { logger: Logger; diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index 29dff74e979..659cd3d96fc 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -1,7 +1,7 @@ import zod from 'zod'; import { OpenTelemetryConfigurationModel } from '@hive/service-common'; import { createConnectionString } from '@hive/storage'; -import { RequestBroker } from './lib/webhooks/schema-change-notification'; +import { RequestBroker } from './lib/webhooks/send-webhook.js'; const isNumberString = (input: unknown) => zod.string().regex(/^\d+$/).safeParse(input).success; diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index f0e7cf6d78c..83ca0c7a7b8 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -51,7 +51,7 @@ function logLevel(level: GraphileLogLevel) { return 'info'; } -let runner: Runner = await run({ +const runner: Runner = await run({ logger: new GraphileLogger(_scope => (level, message, _meta) => { logger[logLevel(level)](message); }), @@ -63,9 +63,16 @@ let runner: Runner = await run({ taskList: Object.fromEntries(modules.map(module => module.task(context))), }); -process.on('SIGINT', () => { - logger.info('Received shutdown signal. Stopping runner.'); - runner.stop().then(() => { +async function shutdown() { + try { + logger.info('Received shutdown signal. Stopping runner.'); + await runner.stop(); logger.info('Runner shutdown successful.'); - }); -}); + logger.info('Shutdown database connection.'); + await pg.end(); + logger.info('Shutdown database connection successful.'); + } catch (error: unknown) { + logger.error({ error }, 'Unepected error occured'); + process.exit(1); + } +} diff --git a/packages/services/workflows/src/lib/expired-schema-checks.ts b/packages/services/workflows/src/lib/expired-schema-checks.ts index 298c06d3657..b65a97b6c6f 100644 --- a/packages/services/workflows/src/lib/expired-schema-checks.ts +++ b/packages/services/workflows/src/lib/expired-schema-checks.ts @@ -1,15 +1,15 @@ import { DatabasePool, sql } from 'slonik'; import { z } from 'zod'; -export async function purgeExpiredSchemaChecks(args: { pool: DatabasePool; expiresAt: Date }) { - const SchemaCheckModel = z.object({ - schemaCheckIds: z.array(z.string()), - sdlStoreIds: z.array(z.string()), - contextIds: z.array(z.string()), - targetIds: z.array(z.string()), - contractIds: z.array(z.string()), - }); +const SchemaCheckModel = z.object({ + schemaCheckIds: z.array(z.string()), + sdlStoreIds: z.array(z.string()), + contextIds: z.array(z.string()), + targetIds: z.array(z.string()), + contractIds: z.array(z.string()), +}); +export async function purgeExpiredSchemaChecks(args: { pool: DatabasePool; expiresAt: Date }) { return await args.pool.transaction(async pool => { const date = args.expiresAt.toISOString(); const rawData = await pool.maybeOne(sql`/* findSchemaChecksToPurge */ diff --git a/packages/services/workflows/src/tasks/password-reset.ts b/packages/services/workflows/src/tasks/password-reset.ts index 7286fb94aed..119757fce7e 100644 --- a/packages/services/workflows/src/tasks/password-reset.ts +++ b/packages/services/workflows/src/tasks/password-reset.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { defineTask, implementTask } from '../kit.js'; -import { renderPasswordResetEmail } from '../lib/emails/templates/password-reset'; +import { renderPasswordResetEmail } from '../lib/emails/templates/password-reset.js'; export const PasswordResetTask = defineTask({ name: 'passwordReset', diff --git a/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts b/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts index b991f0cfcae..1ad7b92f71d 100644 --- a/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts +++ b/packages/services/workflows/src/tasks/purge-expired-schema-checks.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { defineTask, implementTask } from '../kit.js'; -import { purgeExpiredSchemaChecks } from '../lib/expired-schema-checks'; +import { purgeExpiredSchemaChecks } from '../lib/expired-schema-checks.js'; export const PurgeExpiredSchemaChecks = defineTask({ name: 'purgeExpiredSchemaChecks', diff --git a/packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts b/packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts index 5db2ac22e12..ba4a64e9b0f 100644 --- a/packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts +++ b/packages/services/workflows/src/tasks/usage-rate-limit-exceeded.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { defineTask, implementTask } from '../kit.js'; -import { renderRateLimitExceededEmail } from '../lib/emails/templates/rate-limit-exceeded'; +import { renderRateLimitExceededEmail } from '../lib/emails/templates/rate-limit-exceeded.js'; export const UsageRateLimitExceededTask = defineTask({ name: 'usageRateLimitExceeded', diff --git a/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts b/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts index cd7ee716a32..d0876caeca8 100644 --- a/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts +++ b/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { defineTask, implementTask } from '../kit.js'; -import { renderRateLimitWarningEmail } from '../lib/emails/templates/rate-limit-warning'; +import { renderRateLimitWarningEmail } from '../lib/emails/templates/rate-limit-warning.js'; export const UsageRateLimitWarningTask = defineTask({ name: 'usageRateLimitWarning', From 40f766adca7fd39d049af8190534b34b9df776a4 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Tue, 9 Dec 2025 15:25:04 +0100 Subject: [PATCH 13/37] shutdown things --- packages/services/workflows/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index 83ca0c7a7b8..a4250036d13 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -26,6 +26,8 @@ const modules = await Promise.all([ const logger = new Logger({ level: env.log.level }); +logger.info({ pid: process.pid }, 'starting workflow service'); + const context: Context = { logger, email: createEmailProvider(env.email.provider, env.email.emailFrom), From c4eaf2ac2c2b52bd6f1ed95ef9a1aecaaca1e4fe Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 10 Dec 2025 20:45:41 +0100 Subject: [PATCH 14/37] deps --- packages/services/workflows/package.json | 7 ++- .../services/workflows/src/environment.ts | 2 +- pnpm-lock.yaml | 56 +++++++++---------- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/packages/services/workflows/package.json b/packages/services/workflows/package.json index 57e5b4e3ea9..213112c28d4 100644 --- a/packages/services/workflows/package.json +++ b/packages/services/workflows/package.json @@ -8,9 +8,14 @@ "dev": "tsup-node --config ../../../configs/tsup/dev.config.node.ts src/dev.ts", "typecheck": "tsc --noEmit" }, - "dependencies": { + "devDependencies": { "@graphql-hive/logger": "1.0.9", + "@hive/service-common": "workspace:*", + "@hive/storage": "workspace:*", "graphile-worker": "0.16.6", + "nodemailer": "7.0.11", + "sendmail": "1.6.1", + "slonik": "30.4.4", "zod": "3.25.76" } } diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index 659cd3d96fc..952dfca76bb 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -1,5 +1,5 @@ import zod from 'zod'; -import { OpenTelemetryConfigurationModel } from '@hive/service-common'; +import type { OpenTelemetryConfigurationModel } from '@hive/service-common'; import { createConnectionString } from '@hive/storage'; import { RequestBroker } from './lib/webhooks/send-webhook.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03f43a61ad9..6d9a063270e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1719,13 +1719,28 @@ importers: version: 3.25.76 packages/services/workflows: - dependencies: + devDependencies: '@graphql-hive/logger': specifier: 1.0.9 version: 1.0.9 + '@hive/service-common': + specifier: workspace:* + version: link:../service-common + '@hive/storage': + specifier: workspace:* + version: link:../storage graphile-worker: specifier: 0.16.6 version: 0.16.6(typescript@5.7.3) + nodemailer: + specifier: 7.0.11 + version: 7.0.11 + sendmail: + specifier: 1.6.1 + version: 1.6.1 + slonik: + specifier: 30.4.4 + version: 30.4.4(patch_hash=195b140c0181c27a85a6026c0058087a419e38f6c5d89f5f2c608e39f5bf23e9) zod: specifier: 3.25.76 version: 3.25.76 @@ -16374,11 +16389,6 @@ packages: pg-copy-streams@6.0.4: resolution: {integrity: sha512-FH6q2nFo0n2cFacLyIKorjDz8AOYtxrAANx1XMvYbKWNM2geY731gZstuP4mXMlqO6urRl9oIscFxf3GMIg3Ng==} - pg-cursor@2.12.1: - resolution: {integrity: sha512-V13tEaA9Oq1w+V6Q3UBIB/blxJrwbbr35/dY54r/86soBJ7xkP236bXaORUTVXUPt9B6Ql2BQu+uwQiuMfRVgg==} - peerDependencies: - pg: ^8 - pg-cursor@2.15.3: resolution: {integrity: sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA==} peerDependencies: @@ -17533,10 +17543,6 @@ packages: safe-regex@2.1.1: resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} - safe-stable-stringify@2.4.2: - resolution: {integrity: sha512-gMxvPJYhP0O9n2pvcfYfIuYgbledAOJFcqRThtPRmjscaipiwcwPPKLytpVzMkG2HAN87Qmo2d4PtGiri1dSLA==} - engines: {node: '>=10'} - safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -19836,8 +19842,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19989,11 +19995,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0': + '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20032,6 +20038,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -20251,11 +20258,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': + '@aws-sdk/client-sts@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20294,7 +20301,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20526,7 +20532,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20773,7 +20779,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -21162,7 +21168,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 @@ -39405,10 +39411,6 @@ snapshots: dependencies: obuf: 1.1.2 - pg-cursor@2.12.1(pg@8.13.1): - dependencies: - pg: 8.13.1 - pg-cursor@2.15.3(pg@8.13.1): dependencies: pg: 8.13.1 @@ -40583,7 +40585,7 @@ snapshots: fast-json-stringify: 2.7.13 fast-printf: 1.6.9 globalthis: 1.0.3 - safe-stable-stringify: 2.4.2 + safe-stable-stringify: 2.5.0 semver-compare: 1.0.0 robust-predicates@3.0.2: {} @@ -40709,8 +40711,6 @@ snapshots: dependencies: regexp-tree: 0.1.27 - safe-stable-stringify@2.4.2: {} - safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} @@ -41022,7 +41022,7 @@ snapshots: pg: 8.13.1 pg-copy-streams: 6.0.4 pg-copy-streams-binary: 2.2.0 - pg-cursor: 2.12.1(pg@8.13.1) + pg-cursor: 2.15.3(pg@8.13.1) pg-protocol: 1.7.0 pg-types: 4.0.1 postgres-array: 3.0.1 From c90f29752da51ae28bd1d4fd6e277842b29c0d3b Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 10 Dec 2025 21:49:24 +0100 Subject: [PATCH 15/37] observability --- .../services/service-common/src/metrics.ts | 11 ++- .../services/workflows/src/environment.ts | 2 +- packages/services/workflows/src/index.ts | 88 +++++++++---------- packages/services/workflows/src/logger.ts | 34 +++++++ packages/services/workflows/src/metrics.ts | 44 ++++++++++ .../services/workflows/src/task-events.ts | 50 +++++++++++ 6 files changed, 179 insertions(+), 50 deletions(-) create mode 100644 packages/services/workflows/src/logger.ts create mode 100644 packages/services/workflows/src/metrics.ts create mode 100644 packages/services/workflows/src/task-events.ts diff --git a/packages/services/service-common/src/metrics.ts b/packages/services/service-common/src/metrics.ts index ccee87ce9ce..745a181c030 100644 --- a/packages/services/service-common/src/metrics.ts +++ b/packages/services/service-common/src/metrics.ts @@ -13,7 +13,10 @@ export function reportReadiness(isReady: boolean) { readiness.set(isReady ? 1 : 0); } -export async function startMetrics(instanceLabel: string | undefined, port = 10_254) { +export async function startMetrics( + instanceLabel: string | undefined, + port = 10_254, +): Promise<() => Promise> { promClient.collectDefaultMetrics({ labels: { instance: instanceLabel }, }); @@ -26,7 +29,7 @@ export async function startMetrics(instanceLabel: string | undefined, port = 10_ server.route({ method: 'GET', url: '/metrics', - async handler(req, res) { + async handler(_req, res) { try { void res.header('Content-Type', promClient.register.contentType); const result = await promClient.register.metrics(); @@ -40,8 +43,10 @@ export async function startMetrics(instanceLabel: string | undefined, port = 10_ await server.register(cors); - return await server.listen({ + await server.listen({ port, host: '::', }); + + return () => server.close(); } diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index 952dfca76bb..659cd3d96fc 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -1,5 +1,5 @@ import zod from 'zod'; -import type { OpenTelemetryConfigurationModel } from '@hive/service-common'; +import { OpenTelemetryConfigurationModel } from '@hive/service-common'; import { createConnectionString } from '@hive/storage'; import { RequestBroker } from './lib/webhooks/send-webhook.js'; diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index a4250036d13..3bbb88bfc68 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -1,17 +1,16 @@ -import { - Logger as GraphileLogger, - LogLevel as GraphileLogLevel, - run, - Runner, -} from 'graphile-worker'; +import { run } from 'graphile-worker'; import { createPool } from 'slonik'; import { Logger } from '@graphql-hive/logger'; +import { registerShutdown, startMetrics } from '@hive/service-common'; import { Context } from './context.js'; import { env } from './environment.js'; import { createEmailProvider } from './lib/emails/providers.js'; +import { bridgeFastifyLogger, bridgeGraphileLogger } from './logger.js'; +import { createTaskEventEmitter } from './task-events.js'; -const pg = await createPool(env.postgres.connectionString); - +/** + * Registered Task Definitions. + */ const modules = await Promise.all([ import('./tasks/audit-log-export.js'), import('./tasks/email-verification.js'), @@ -24,6 +23,12 @@ const modules = await Promise.all([ import('./tasks/usage-rate-limit-warning.js'), ]); +const crontab = ` + # Purge expired schema checks every Sunday at 10:00AM + 0 10 * * 0 purgeExpiredSchemaChecks +`; + +const pg = await createPool(env.postgres.connectionString); const logger = new Logger({ level: env.log.level }); logger.info({ pid: process.pid }, 'starting workflow service'); @@ -35,46 +40,37 @@ const context: Context = { requestBroker: env.requestBroker, }; -function logLevel(level: GraphileLogLevel) { - switch (level) { - case 'warning': - return 'warn' as const; - case 'info': { - return 'info' as const; - } - case 'debug': { - return 'debug' as const; - } - case 'error': { - return 'error' as const; - } - } - - return 'info'; -} +const shutdownMetrics = env.prometheus + ? await startMetrics(env.prometheus.labels.instance, env.prometheus.port) + : null; -const runner: Runner = await run({ - logger: new GraphileLogger(_scope => (level, message, _meta) => { - logger[logLevel(level)](message); - }), - crontab: ` - # Purge expired schema checks every Saturday at 10:00AM - 0 10 * * 0 purgeExpiredSchemaChecks - `, +const runner = await run({ + logger: bridgeGraphileLogger(logger), + crontab, pgPool: pg.pool, taskList: Object.fromEntries(modules.map(module => module.task(context))), + noHandleSignals: true, + events: createTaskEventEmitter(), }); -async function shutdown() { - try { - logger.info('Received shutdown signal. Stopping runner.'); - await runner.stop(); - logger.info('Runner shutdown successful.'); - logger.info('Shutdown database connection.'); - await pg.end(); - logger.info('Shutdown database connection successful.'); - } catch (error: unknown) { - logger.error({ error }, 'Unepected error occured'); - process.exit(1); - } -} +registerShutdown({ + logger: bridgeFastifyLogger(logger), + async onShutdown() { + try { + logger.info('Stopping task runner.'); + await runner.stop(); + logger.info('Task runner shutdown successful.'); + logger.info('Shutdown postgres connection.'); + await pg.end(); + logger.info('Shutdown postgres connection successful.'); + if (shutdownMetrics) { + logger.info('Stopping prometheus endpoint'); + await shutdownMetrics(); + logger.info('Stopping prometheus endpoint successful.'); + } + } catch (error: unknown) { + logger.error({ error }, 'Unepected error occured'); + process.exit(1); + } + }, +}); diff --git a/packages/services/workflows/src/logger.ts b/packages/services/workflows/src/logger.ts new file mode 100644 index 00000000000..094c3f782c4 --- /dev/null +++ b/packages/services/workflows/src/logger.ts @@ -0,0 +1,34 @@ +import { Logger as GraphileLogger, type LogLevel as GraphileLogLevel } from 'graphile-worker'; +import type { Logger } from '@graphql-hive/logger'; +import { ServiceLogger } from '@hive/service-common'; + +function logLevel(level: GraphileLogLevel) { + switch (level) { + case 'warning': + return 'warn' as const; + case 'info': { + return 'info' as const; + } + case 'debug': { + return 'debug' as const; + } + case 'error': { + return 'error' as const; + } + } + + return 'info'; +} + +/** + * Bridges Hive Logger to Graphile Logger + */ +export function bridgeGraphileLogger(logger: Logger) { + return new GraphileLogger(_scope => (level, message, _meta) => { + logger[logLevel(level)](message); + }); +} + +export function bridgeFastifyLogger(logger: Logger): ServiceLogger { + return logger as unknown as ServiceLogger; +} diff --git a/packages/services/workflows/src/metrics.ts b/packages/services/workflows/src/metrics.ts new file mode 100644 index 00000000000..ae2f4638647 --- /dev/null +++ b/packages/services/workflows/src/metrics.ts @@ -0,0 +1,44 @@ +import { metrics } from '@hive/service-common'; + +export const jobCompleteCounter = new metrics.Counter({ + name: 'hive_workflow_job_complete_total', + help: 'Total number of completed jobs', + labelNames: ['task_identifier'], +}); + +export const jobErrorCounter = new metrics.Counter({ + name: 'hive_workflow_job_error_total', + help: 'Total number of jobs with errors', + labelNames: ['task_identifier'], +}); + +export const jobSuccessCounter = new metrics.Counter({ + name: 'hive_workflow_job_success_total', + help: 'Total number of successful jobs', + labelNames: ['task_identifier'], +}); + +export const jobFailedCounter = new metrics.Counter({ + name: 'hive_workflow_job_failed_total', + help: 'Total number of failed jobs', + labelNames: ['task_identifier'], +}); + +export const workerFatalErrorCounter = new metrics.Counter({ + name: 'hive_workflow_worker_fatal_error_total', + help: 'Total number of worker fatal errors', +}); + +export const jobDuration = new metrics.Summary({ + name: 'hive_workflow_job_duration_seconds', + help: 'Duration of jobs in seconds', + labelNames: ['task_identifier'], + percentiles: [0.5, 0.9, 0.95, 0.99], +}); + +export const jobQueueTime = new metrics.Summary({ + name: 'hive_workflow_job_queue_time_seconds', + help: 'Time a job spends in the queue in seconds', + labelNames: ['task_identifier'], + percentiles: [0.5, 0.9, 0.95, 0.99], +}); diff --git a/packages/services/workflows/src/task-events.ts b/packages/services/workflows/src/task-events.ts new file mode 100644 index 00000000000..68528b152de --- /dev/null +++ b/packages/services/workflows/src/task-events.ts @@ -0,0 +1,50 @@ +import EventEmitter from 'events'; +import type { WorkerEvents } from 'graphile-worker'; +import { + jobCompleteCounter, + jobDuration, + jobErrorCounter, + jobFailedCounter, + jobQueueTime, + jobSuccessCounter, + workerFatalErrorCounter, +} from './metrics'; + +/** + * Creates an event emitter with handlers for prometheus metrics for the Graphile Worker + */ +export function createTaskEventEmitter() { + const events: WorkerEvents = new EventEmitter(); + + events.on('job:start', ({ job }) => { + const queueTimeInSeconds = + (Date.now() - (new Date(job.run_at) ?? new Date(job.created_at)).getTime()) / 1000; + jobQueueTime.observe({ task_identifier: job.task_identifier }, queueTimeInSeconds); + }); + + events.on('job:complete', ({ job }) => { + jobCompleteCounter.inc({ task_identifier: job.task_identifier }); + jobDuration.observe( + { task_identifier: job.task_identifier }, + (Date.now() - (new Date(job.run_at) ?? new Date(job.created_at)).getTime()) / 1000, + ); + }); + + events.on('job:error', ({ job }) => { + jobErrorCounter.inc({ task_identifier: job.task_identifier }); + }); + + events.on('job:success', ({ job }) => { + jobSuccessCounter.inc({ task_identifier: job.task_identifier }); + }); + + events.on('job:failed', ({ job }) => { + jobFailedCounter.inc({ task_identifier: job.task_identifier }); + }); + + events.on('worker:fatalError', () => { + workerFatalErrorCounter.inc(); + }); + + return events; +} From 0fb0adc635e1d3a4f1fbe8dfbdfcb2215e7d6cc9 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 10 Dec 2025 22:06:56 +0100 Subject: [PATCH 16/37] build setup --- docker/docker-compose.community.yml | 22 +++++++++++++- docker/docker.hcl | 23 +++++++++++++++ packages/services/api/package.json | 2 -- packages/services/commerce/package.json | 1 - .../services/workflows/src/environment.ts | 8 +++-- packages/services/workflows/src/index.ts | 12 ++++++++ pnpm-lock.yaml | 29 +++++++------------ tsconfig.json | 2 -- 8 files changed, 71 insertions(+), 28 deletions(-) diff --git a/docker/docker-compose.community.yml b/docker/docker-compose.community.yml index eba9ed63d18..b57286af2ed 100644 --- a/docker/docker-compose.community.yml +++ b/docker/docker-compose.community.yml @@ -341,7 +341,27 @@ services: EMAIL_FROM: no-reply@graphql-hive.com EMAIL_PROVIDER: sendmail LOG_LEVEL: '${LOG_LEVEL:-debug}' - OPENTELEMETRY_COLLECTOR_ENDPOINT: '${OPENTELEMETRY_COLLECTOR_ENDPOINT:-}' + SENTRY: '${SENTRY:-0}' + SENTRY_DSN: '${SENTRY_DSN:-}' + PROMETHEUS_METRICS: '${PROMETHEUS_METRICS:-}' + + workflows: + image: '${DOCKER_REGISTRY}workflows${DOCKER_TAG}' + networks: + - 'stack' + depends_on: + db: + condition: service_healthy + environment: + NODE_ENV: production + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_DB: '${POSTGRES_DB}' + POSTGRES_USER: '${POSTGRES_USER}' + POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}' + EMAIL_FROM: no-reply@graphql-hive.com + EMAIL_PROVIDER: sendmail + LOG_LEVEL: '${LOG_LEVEL:-debug}' SENTRY: '${SENTRY:-0}' SENTRY_DSN: '${SENTRY_DSN:-}' PROMETHEUS_METRICS: '${PROMETHEUS_METRICS:-}' diff --git a/docker/docker.hcl b/docker/docker.hcl index cf6f2cf36e5..abf631abd24 100644 --- a/docker/docker.hcl +++ b/docker/docker.hcl @@ -316,6 +316,27 @@ target "webhooks" { ] } +target "workflows" { + inherits = ["service-base", get_target()] + contexts = { + dist = "${PWD}/packages/services/workflows/dist" + shared = "${PWD}/docker/shared" + } + args = { + SERVICE_DIR_NAME = "@hive/workflows" + IMAGE_TITLE = "graphql-hive/workflows" + IMAGE_DESCRIPTION = "The workflow service of the GraphQL Hive project." + PORT = "3005" + HEALTHCHECK_CMD = "wget --spider -q http://127.0.0.1:$${PORT}/_readiness" + } + tags = [ + local_image_tag("workflows"), + stable_image_tag("workflows"), + image_tag("workflows", COMMIT_SHA), + image_tag("workflows", BRANCH_NAME) + ] +} + target "composition-federation-2" { inherits = ["service-base", get_target()] contexts = { @@ -424,6 +445,7 @@ group "build" { "commerce", "composition-federation-2", "app", + "workflows", "otel-collector" ] } @@ -441,6 +463,7 @@ group "integration-tests" { "webhooks", "server", "composition-federation-2", + "workflows", "otel-collector" ] } diff --git a/packages/services/api/package.json b/packages/services/api/package.json index 00574c5ba3d..58f132e7351 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -20,14 +20,12 @@ "@graphql-inspector/core": "7.0.3", "@graphql-tools/merge": "9.1.1", "@hive/cdn-script": "workspace:*", - "@hive/emails": "workspace:*", "@hive/schema": "workspace:*", "@hive/service-common": "workspace:*", "@hive/storage": "workspace:*", "@hive/tokens": "workspace:*", "@hive/usage-common": "workspace:*", "@hive/usage-ingestor": "workspace:*", - "@hive/webhooks": "workspace:*", "@nodesecure/i18n": "^4.0.1", "@nodesecure/js-x-ray": "8.0.0", "@octokit/app": "15.1.4", diff --git a/packages/services/commerce/package.json b/packages/services/commerce/package.json index 864e8f75c7e..79fbdad01f9 100644 --- a/packages/services/commerce/package.json +++ b/packages/services/commerce/package.json @@ -10,7 +10,6 @@ }, "devDependencies": { "@hive/api": "workspace:*", - "@hive/emails": "workspace:*", "@hive/service-common": "workspace:*", "@hive/storage": "workspace:*", "@sentry/node": "7.120.2", diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index 659cd3d96fc..1c438f33655 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -94,9 +94,11 @@ const RequestBrokerModel = zod.union([ ]); const PrometheusModel = zod.object({ - PROMETHEUS_METRICS: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), - PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()), - PROMETHEUS_METRICS_PORT: emptyString(NumberFromString.optional()), + PROMETHEUS_METRICS: emptyString( + zod.union([zod.literal('0'), zod.literal('1')]).optional(), + ).default('0'), + PROMETHEUS_METRICS_LABEL_INSTANCE: emptyString(zod.string().optional()).default('workflows'), + PROMETHEUS_METRICS_PORT: emptyString(NumberFromString.optional()).default(10254), }); const LogModel = zod.object({ diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index 3bbb88bfc68..3fa95174d11 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -1,13 +1,25 @@ +import { hostname } from 'node:os'; import { run } from 'graphile-worker'; import { createPool } from 'slonik'; import { Logger } from '@graphql-hive/logger'; import { registerShutdown, startMetrics } from '@hive/service-common'; +import * as Sentry from '@sentry/node'; import { Context } from './context.js'; import { env } from './environment.js'; import { createEmailProvider } from './lib/emails/providers.js'; import { bridgeFastifyLogger, bridgeGraphileLogger } from './logger.js'; import { createTaskEventEmitter } from './task-events.js'; +if (env.sentry) { + Sentry.init({ + serverName: hostname(), + dist: 'workflows', + environment: env.environment, + dsn: env.sentry.dsn, + release: env.release, + }); +} + /** * Registered Task Definitions. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d9a063270e..ad22361878d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -758,9 +758,6 @@ importers: '@hive/cdn-script': specifier: workspace:* version: link:../cdn-worker - '@hive/emails': - specifier: workspace:* - version: link:../emails '@hive/schema': specifier: workspace:* version: link:../schema @@ -779,9 +776,6 @@ importers: '@hive/usage-ingestor': specifier: workspace:* version: link:../usage-ingestor - '@hive/webhooks': - specifier: workspace:* - version: link:../webhooks '@nodesecure/i18n': specifier: ^4.0.1 version: 4.0.1 @@ -1004,9 +998,6 @@ importers: '@hive/api': specifier: workspace:* version: link:../api - '@hive/emails': - specifier: workspace:* - version: link:../emails '@hive/service-common': specifier: workspace:* version: link:../service-common @@ -19842,8 +19833,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19995,11 +19986,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': + '@aws-sdk/client-sso-oidc@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20038,7 +20029,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -20258,11 +20248,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0': + '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20301,6 +20291,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20532,7 +20523,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20779,7 +20770,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -21168,7 +21159,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 diff --git a/tsconfig.json b/tsconfig.json index 9f35a66e39c..0925ff8847e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -48,8 +48,6 @@ "@hive/usage-ingestor": ["./packages/services/usage-ingestor/src/index.ts"], "@hive/policy": ["./packages/services/policy/src/api.ts"], "@hive/tokens": ["./packages/services/tokens/src/api.ts"], - "@hive/webhooks": ["./packages/services/webhooks/src/api.ts"], - "@hive/emails": ["./packages/services/emails/src/api.ts"], "@hive/commerce": ["./packages/services/commerce/src/api.ts"], "@hive/storage": ["./packages/services/storage/src/index.ts"], "@hive/storage/*": ["./packages/services/storage/src/*"], From 5112ce0244d6e2c74aab14a7eb6ea4944264216f Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 10 Dec 2025 22:16:01 +0100 Subject: [PATCH 17/37] heartbeat --- packages/services/workflows/src/environment.ts | 1 + packages/services/workflows/src/index.ts | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index 1c438f33655..7bbbbebd948 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -234,4 +234,5 @@ export const env = { signature: requestBroker.REQUEST_BROKER_SIGNATURE, } satisfies RequestBroker) : null, + heartbeat: base.HEARTBEAT_ENDPOINT ? { endpoint: base.HEARTBEAT_ENDPOINT } : null, } as const; diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index 3fa95174d11..58cc92971b8 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -2,7 +2,7 @@ import { hostname } from 'node:os'; import { run } from 'graphile-worker'; import { createPool } from 'slonik'; import { Logger } from '@graphql-hive/logger'; -import { registerShutdown, startMetrics } from '@hive/service-common'; +import { registerShutdown, startHeartbeats, startMetrics } from '@hive/service-common'; import * as Sentry from '@sentry/node'; import { Context } from './context.js'; import { env } from './environment.js'; @@ -45,6 +45,16 @@ const logger = new Logger({ level: env.log.level }); logger.info({ pid: process.pid }, 'starting workflow service'); +const stopHeartbeats = env.heartbeat + ? startHeartbeats({ + enabled: true, + endpoint: env.heartbeat.endpoint, + intervalInMS: 20_000, + onError: error => logger.error({ error }, 'Heartbeat failed.'), + isReady: () => true, + }) + : null; + const context: Context = { logger, email: createEmailProvider(env.email.provider, env.email.emailFrom), @@ -80,6 +90,11 @@ registerShutdown({ await shutdownMetrics(); logger.info('Stopping prometheus endpoint successful.'); } + if (stopHeartbeats) { + logger.info('Stop heartbeat'); + stopHeartbeats(); + logger.info('Heartbeat stopped'); + } } catch (error: unknown) { logger.error({ error }, 'Unepected error occured'); process.exit(1); From a6dadaafa74b6d1de00fe968da35803871067a92 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 10 Dec 2025 22:43:39 +0100 Subject: [PATCH 18/37] file system heartbeat anyone --- docker/docker-compose.community.yml | 1 + docker/docker.hcl | 2 +- .../services/workflows/src/environment.ts | 2 +- packages/services/workflows/src/heartbeat.ts | 27 +++++++++++++++++++ packages/services/workflows/src/index.ts | 15 ++++++----- 5 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 packages/services/workflows/src/heartbeat.ts diff --git a/docker/docker-compose.community.yml b/docker/docker-compose.community.yml index b57286af2ed..49d8f4823da 100644 --- a/docker/docker-compose.community.yml +++ b/docker/docker-compose.community.yml @@ -365,6 +365,7 @@ services: SENTRY: '${SENTRY:-0}' SENTRY_DSN: '${SENTRY_DSN:-}' PROMETHEUS_METRICS: '${PROMETHEUS_METRICS:-}' + LOG_JSON: '1' usage: image: '${DOCKER_REGISTRY}usage${DOCKER_TAG}' diff --git a/docker/docker.hcl b/docker/docker.hcl index abf631abd24..927bc2f7c60 100644 --- a/docker/docker.hcl +++ b/docker/docker.hcl @@ -327,7 +327,7 @@ target "workflows" { IMAGE_TITLE = "graphql-hive/workflows" IMAGE_DESCRIPTION = "The workflow service of the GraphQL Hive project." PORT = "3005" - HEALTHCHECK_CMD = "wget --spider -q http://127.0.0.1:$${PORT}/_readiness" + HEALTHCHECK_CMD = "test $(($(date +%s) - $(cat /tmp/hive_worker_heartbeat))) -lt 60 || exit 1" } tags = [ local_image_tag("workflows"), diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index 7bbbbebd948..b954f368afd 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -234,5 +234,5 @@ export const env = { signature: requestBroker.REQUEST_BROKER_SIGNATURE, } satisfies RequestBroker) : null, - heartbeat: base.HEARTBEAT_ENDPOINT ? { endpoint: base.HEARTBEAT_ENDPOINT } : null, + httpHeartbeat: base.HEARTBEAT_ENDPOINT ? { endpoint: base.HEARTBEAT_ENDPOINT } : null, } as const; diff --git a/packages/services/workflows/src/heartbeat.ts b/packages/services/workflows/src/heartbeat.ts new file mode 100644 index 00000000000..86358a47973 --- /dev/null +++ b/packages/services/workflows/src/heartbeat.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs/promises'; +import path from 'path'; +import { Logger } from '@graphql-hive/logger'; + +/** Write latest date to filesystem for docker health check */ +export async function startHeartbeat(logger: Logger) { + const file = '/tmp/hive_worker_heartbeat'; + const intervalMs = 10_000; + + const dir = path.dirname(file); + + // Make sure directory exists + await fs.mkdir(dir, { recursive: true }); + + const writeHeartbeat = async () => { + try { + const timestamp = Math.floor(Date.now() / 1000).toString(); + await fs.writeFile(file, timestamp); + } catch (errror) { + logger.error({ errror }, 'Heartbeat write failed:'); + } + + setTimeout(writeHeartbeat, intervalMs).unref(); + }; + + writeHeartbeat(); +} diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index 58cc92971b8..3945da3cff2 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -6,6 +6,7 @@ import { registerShutdown, startHeartbeats, startMetrics } from '@hive/service-c import * as Sentry from '@sentry/node'; import { Context } from './context.js'; import { env } from './environment.js'; +import { startHeartbeat } from './heartbeat.js'; import { createEmailProvider } from './lib/emails/providers.js'; import { bridgeFastifyLogger, bridgeGraphileLogger } from './logger.js'; import { createTaskEventEmitter } from './task-events.js'; @@ -45,16 +46,18 @@ const logger = new Logger({ level: env.log.level }); logger.info({ pid: process.pid }, 'starting workflow service'); -const stopHeartbeats = env.heartbeat +const stopHttpHeartbeat = env.httpHeartbeat ? startHeartbeats({ enabled: true, - endpoint: env.heartbeat.endpoint, + endpoint: env.httpHeartbeat.endpoint, intervalInMS: 20_000, onError: error => logger.error({ error }, 'Heartbeat failed.'), isReady: () => true, }) : null; +startHeartbeat(logger); + const context: Context = { logger, email: createEmailProvider(env.email.provider, env.email.emailFrom), @@ -90,10 +93,10 @@ registerShutdown({ await shutdownMetrics(); logger.info('Stopping prometheus endpoint successful.'); } - if (stopHeartbeats) { - logger.info('Stop heartbeat'); - stopHeartbeats(); - logger.info('Heartbeat stopped'); + if (stopHttpHeartbeat) { + logger.info('Stop HTTP heartbeat'); + stopHttpHeartbeat(); + logger.info('HTTP heartbeat stopped'); } } catch (error: unknown) { logger.error({ error }, 'Unepected error occured'); From 271ac30841311675a2fa8e585edaeed2d96fa3a4 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 10 Dec 2025 22:52:28 +0100 Subject: [PATCH 19/37] deployment --- deployment/index.ts | 11 +++++ deployment/services/workflows.ts | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 deployment/services/workflows.ts diff --git a/deployment/index.ts b/deployment/index.ts index 6c1a3aa29fe..58397b7b738 100644 --- a/deployment/index.ts +++ b/deployment/index.ts @@ -30,6 +30,7 @@ import { deployTokens } from './services/tokens'; import { deployUsage } from './services/usage'; import { deployUsageIngestor } from './services/usage-ingestor'; import { deployWebhooks } from './services/webhooks'; +import { deployWorkflows } from './services/workflows'; import { configureZendesk } from './services/zendesk'; import { optimizeAzureCluster } from './utils/azure-helpers'; import { isDefined } from './utils/helpers'; @@ -150,6 +151,16 @@ const emails = deployEmails({ observability, }); +deployWorkflows({ + image: docker.factory.getImageId('workflows', imagesTag), + docker, + environment, + postgres, + observability, + sentry, + heartbeat: heartbeatsConfig.get('workflows'), +}); + const commerce = deployCommerce({ image: docker.factory.getImageId('commerce', imagesTag), docker, diff --git a/deployment/services/workflows.ts b/deployment/services/workflows.ts new file mode 100644 index 00000000000..bfcc60e23d8 --- /dev/null +++ b/deployment/services/workflows.ts @@ -0,0 +1,79 @@ +import * as pulumi from '@pulumi/pulumi'; +import { ServiceSecret } from '../utils/secrets'; +import { ServiceDeployment } from '../utils/service-deployment'; +import { Docker } from './docker'; +import { Environment } from './environment'; +import { Observability } from './observability'; +import { Postgres } from './postgres'; +import { Sentry } from './sentry'; + +class PostmarkSecret extends ServiceSecret<{ + token: pulumi.Output | string; + from: string; + messageStream: string; +}> {} + +export function deployWorkflows({ + environment, + heartbeat, + image, + docker, + sentry, + postgres, + observability, +}: { + postgres: Postgres; + observability: Observability; + environment: Environment; + image: string; + docker: Docker; + heartbeat?: string; + sentry: Sentry; +}) { + const emailConfig = new pulumi.Config('email'); + const postmarkSecret = new PostmarkSecret('postmark', { + token: emailConfig.requireSecret('token'), + from: emailConfig.require('from'), + messageStream: emailConfig.require('messageStream'), + }); + + return ( + new ServiceDeployment( + 'workflow-service', + { + imagePullSecret: docker.secret, + env: { + ...environment.envVars, + SENTRY: sentry.enabled ? '1' : '0', + EMAIL_PROVIDER: 'postmark', + HEARTBEAT_ENDPOINT: heartbeat ?? '', + OPENTELEMETRY_COLLECTOR_ENDPOINT: + observability.enabled && observability.tracingEndpoint + ? observability.tracingEndpoint + : '', + LOG_JSON: '1', + }, + // TODO: do I really need to add HTTP for these? :ok: + // readinessProbe: '/_readiness', + // livenessProbe: '/_health', + // startupProbe: '/_health', + exposesMetrics: true, + image, + replicas: environment.podsConfig.general.replicas, + }, + [], + ) + // PG + .withSecret('POSTGRES_HOST', postgres.pgBouncerSecret, 'host') + .withSecret('POSTGRES_PORT', postgres.pgBouncerSecret, 'port') + .withSecret('POSTGRES_USER', postgres.pgBouncerSecret, 'user') + .withSecret('POSTGRES_PASSWORD', postgres.pgBouncerSecret, 'password') + .withSecret('POSTGRES_DB', postgres.pgBouncerSecret, 'database') + .withSecret('POSTGRES_SSL', postgres.pgBouncerSecret, 'ssl') + .withSecret('EMAIL_FROM', postmarkSecret, 'from') + .withSecret('EMAIL_PROVIDER_POSTMARK_TOKEN', postmarkSecret, 'token') + .withSecret('EMAIL_PROVIDER_POSTMARK_MESSAGE_STREAM', postmarkSecret, 'messageStream') + .withConditionalSecret(sentry.enabled, 'SENTRY_DSN', sentry.secret, 'dsn') + .deploy() + ); +} From 4ec6fe3a4363511abc4e834a2a856f8a9cda4652 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 10 Dec 2025 23:01:18 +0100 Subject: [PATCH 20/37] cleanup --- deployment/services/commerce.ts | 1 - deployment/services/graphql.ts | 2 -- docker/docker-compose.community.yml | 2 -- .../docker-compose.integration.yaml | 1 - packages/services/api/package.json | 1 + packages/services/api/src/create.ts | 12 +------ .../src/modules/alerts/providers/tokens.ts | 7 ---- packages/services/commerce/package.json | 1 + packages/services/commerce/src/environment.ts | 4 --- packages/services/server/.env.template | 3 +- packages/services/server/README.md | 2 -- packages/services/server/package.json | 1 + packages/services/server/src/environment.ts | 4 --- packages/services/server/src/index.ts | 4 --- packages/services/workflows/package.json | 1 + packages/services/workflows/src/heartbeat.ts | 2 +- packages/services/workflows/src/index.ts | 2 +- pnpm-lock.yaml | 32 +++++++++++++------ 18 files changed, 30 insertions(+), 52 deletions(-) delete mode 100644 packages/services/api/src/modules/alerts/providers/tokens.ts diff --git a/deployment/services/commerce.ts b/deployment/services/commerce.ts index 599950a6ed4..1fabb3ea325 100644 --- a/deployment/services/commerce.ts +++ b/deployment/services/commerce.ts @@ -56,7 +56,6 @@ export function deployCommerce({ env: { ...environment.envVars, SENTRY: sentry.enabled ? '1' : '0', - EMAILS_ENDPOINT: serviceLocalEndpoint(emails.service), WEB_APP_URL: `https://${environment.appDns}/`, OPENTELEMETRY_TRACE_USAGE_REQUESTS: observability.enabledForUsageService ? '1' : '', OPENTELEMETRY_COLLECTOR_ENDPOINT: diff --git a/deployment/services/graphql.ts b/deployment/services/graphql.ts index 1cb0919f6d9..d2cab7c0fb4 100644 --- a/deployment/services/graphql.ts +++ b/deployment/services/graphql.ts @@ -126,10 +126,8 @@ export function deployGraphQL({ REQUEST_LOGGING: '1', // disabled COMMERCE_ENDPOINT: serviceLocalEndpoint(commerce.service), TOKENS_ENDPOINT: serviceLocalEndpoint(tokens.service), - WEBHOOKS_ENDPOINT: serviceLocalEndpoint(webhooks.service), SCHEMA_ENDPOINT: serviceLocalEndpoint(schema.service), SCHEMA_POLICY_ENDPOINT: serviceLocalEndpoint(schemaPolicy.service), - EMAILS_ENDPOINT: serviceLocalEndpoint(emails.service), WEB_APP_URL: `https://${environment.appDns}`, GRAPHQL_PUBLIC_ORIGIN: `https://${environment.appDns}`, CDN_CF: '1', diff --git a/docker/docker-compose.community.yml b/docker/docker-compose.community.yml index 49d8f4823da..e4ec368e857 100644 --- a/docker/docker-compose.community.yml +++ b/docker/docker-compose.community.yml @@ -218,10 +218,8 @@ services: REDIS_PORT: 6379 REDIS_PASSWORD: '${REDIS_PASSWORD}' TOKENS_ENDPOINT: http://tokens:3003 - WEBHOOKS_ENDPOINT: http://webhooks:3005 SCHEMA_ENDPOINT: http://schema:3002 SCHEMA_POLICY_ENDPOINT: http://policy:3012 - EMAILS_ENDPOINT: http://emails:3011 ENCRYPTION_SECRET: '${HIVE_ENCRYPTION_SECRET}' WEB_APP_URL: '${HIVE_APP_BASE_URL}' PORT: 3001 diff --git a/integration-tests/docker-compose.integration.yaml b/integration-tests/docker-compose.integration.yaml index b707f266b4e..3818f854651 100644 --- a/integration-tests/docker-compose.integration.yaml +++ b/integration-tests/docker-compose.integration.yaml @@ -138,7 +138,6 @@ services: CLICKHOUSE_PORT: '8123' CLICKHOUSE_USERNAME: '${CLICKHOUSE_USER}' CLICKHOUSE_PASSWORD: '${CLICKHOUSE_PASSWORD}' - EMAILS_ENDPOINT: http://emails:3011 STRIPE_SECRET_KEY: empty PORT: 3009 diff --git a/packages/services/api/package.json b/packages/services/api/package.json index 58f132e7351..2f3fe4c125a 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -26,6 +26,7 @@ "@hive/tokens": "workspace:*", "@hive/usage-common": "workspace:*", "@hive/usage-ingestor": "workspace:*", + "@hive/workflows": "workspace:*", "@nodesecure/i18n": "^4.0.1", "@nodesecure/js-x-ray": "8.0.0", "@octokit/app": "15.1.4", diff --git a/packages/services/api/src/create.ts b/packages/services/api/src/create.ts index b8405d3a3c4..d382537b167 100644 --- a/packages/services/api/src/create.ts +++ b/packages/services/api/src/create.ts @@ -3,7 +3,6 @@ import { Redis } from 'ioredis'; import { TaskScheduler } from '@hive/workflows/kit'; import { adminModule } from './modules/admin'; import { alertsModule } from './modules/alerts'; -import { WEBHOOKS_CONFIG, WebhooksConfig } from './modules/alerts/providers/tokens'; import { appDeploymentsModule } from './modules/app-deployments'; import { APP_DEPLOYMENTS_ENABLED } from './modules/app-deployments/providers/app-deployments-enabled-token'; import { auditLogsModule } from './modules/audit-logs'; @@ -89,13 +88,13 @@ const modules = [ collectionModule, appDeploymentsModule, auditLogsModule, + supportModule, ]; export function createRegistry({ app, commerce, tokens, - webhooks, schemaService, schemaPolicyService, logger, @@ -110,7 +109,6 @@ export function createRegistry({ encryptionSecret, schemaConfig, supportConfig, - emailsEndpoint, organizationOIDC, pubSub, appDeploymentsEnabled, @@ -124,7 +122,6 @@ export function createRegistry({ redis: Redis; commerce: CommerceConfig; tokens: TokensConfig; - webhooks: WebhooksConfig; schemaService: SchemaServiceConfig; schemaPolicyService: SchemaPolicyServiceConfig; githubApp: GitHubApplicationConfig | null; @@ -156,7 +153,6 @@ export function createRegistry({ } | null; schemaConfig: SchemaModuleConfig; supportConfig: SupportConfig | null; - emailsEndpoint?: string; organizationOIDC: boolean; pubSub: HivePubSub; appDeploymentsEnabled: boolean; @@ -242,12 +238,6 @@ export function createRegistry({ useValue: tokens, scope: Scope.Singleton, }, - - { - provide: WEBHOOKS_CONFIG, - useValue: webhooks, - scope: Scope.Singleton, - }, { provide: SCHEMA_SERVICE_CONFIG, useValue: schemaService, diff --git a/packages/services/api/src/modules/alerts/providers/tokens.ts b/packages/services/api/src/modules/alerts/providers/tokens.ts deleted file mode 100644 index 4646d0b6e66..00000000000 --- a/packages/services/api/src/modules/alerts/providers/tokens.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { InjectionToken } from 'graphql-modules'; - -export interface WebhooksConfig { - endpoint: string; -} - -export const WEBHOOKS_CONFIG = new InjectionToken('webhooks-endpoint'); diff --git a/packages/services/commerce/package.json b/packages/services/commerce/package.json index 79fbdad01f9..1cce7eada9b 100644 --- a/packages/services/commerce/package.json +++ b/packages/services/commerce/package.json @@ -12,6 +12,7 @@ "@hive/api": "workspace:*", "@hive/service-common": "workspace:*", "@hive/storage": "workspace:*", + "@hive/workflows": "workspace:*", "@sentry/node": "7.120.2", "@trpc/client": "10.45.2", "@trpc/server": "10.45.2", diff --git a/packages/services/commerce/src/environment.ts b/packages/services/commerce/src/environment.ts index 11a1a989636..98f8ee24170 100644 --- a/packages/services/commerce/src/environment.ts +++ b/packages/services/commerce/src/environment.ts @@ -79,7 +79,6 @@ const PostgresModel = zod.object({ }); const HiveServicesModel = zod.object({ - EMAILS_ENDPOINT: emptyString(zod.string().url().optional()), WEB_APP_URL: emptyString(zod.string().url().optional()), }); @@ -177,9 +176,6 @@ export const env = { } : null, hiveServices: { - emails: { - endpoint: hiveServices.EMAILS_ENDPOINT, - }, webAppUrl: hiveServices.WEB_APP_URL, }, rateLimit: { diff --git a/packages/services/server/.env.template b/packages/services/server/.env.template index 58e7657ea6a..9c20344126d 100644 --- a/packages/services/server/.env.template +++ b/packages/services/server/.env.template @@ -15,8 +15,7 @@ TOKENS_ENDPOINT="http://localhost:6001" SCHEMA_ENDPOINT="http://localhost:6500" SCHEMA_POLICY_ENDPOINT="http://localhost:6600" COMMERCE_ENDPOINT="http://localhost:4013" -WEBHOOKS_ENDPOINT="http://localhost:6250" -EMAILS_ENDPOINT="http://localhost:6260" + REDIS_HOST="localhost" REDIS_PORT="6379" REDIS_PASSWORD="" diff --git a/packages/services/server/README.md b/packages/services/server/README.md index 112b0c6299b..75aa673981d 100644 --- a/packages/services/server/README.md +++ b/packages/services/server/README.md @@ -10,9 +10,7 @@ The GraphQL API for GraphQL Hive. | `ENCRYPTION_SECRET` | **Yes** | Secret for encrypting stuff. | `8ebe95cg21c1fee355e9fa32c8c33141` | | `WEB_APP_URL` | **Yes** | The url of the web app. | `http://127.0.0.1:3000` | | `GRAPHQL_PUBLIC_ORIGIN` | **Yes** | The origin of the GraphQL server. | `http://127.0.0.1:4013` | -| `EMAILS_ENDPOINT` | **Yes** | The endpoint of the GraphQL Hive Email service. | `http://127.0.0.1:6260` | | `TOKENS_ENDPOINT` | **Yes** | The endpoint of the tokens service. | `http://127.0.0.1:6001` | -| `WEBHOOKS_ENDPOINT` | **Yes** | The endpoint of the webhooks service. | `http://127.0.0.1:6250` | | `SCHEMA_ENDPOINT` | **Yes** | The endpoint of the schema service. | `http://127.0.0.1:6500` | | `SCHEMA_POLICY_ENDPOINT` | **No** | The endpoint of the schema policy service. | `http://127.0.0.1:6600` | | `POSTGRES_SSL` | No | Whether the postgres connection should be established via SSL. | `1` (enabled) or `0` (disabled) | diff --git a/packages/services/server/package.json b/packages/services/server/package.json index 0c9896c58ac..5befdc8e068 100644 --- a/packages/services/server/package.json +++ b/packages/services/server/package.json @@ -30,6 +30,7 @@ "@hive/schema": "workspace:*", "@hive/service-common": "workspace:*", "@hive/storage": "workspace:*", + "@hive/workflows": "workspace:*", "@sentry/integrations": "7.114.0", "@sentry/node": "7.120.2", "@swc/core": "1.13.5", diff --git a/packages/services/server/src/environment.ts b/packages/services/server/src/environment.ts index 5d7c6312bb3..9d427403de2 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -32,8 +32,6 @@ const EnvironmentModel = zod.object({ .url(), SCHEMA_POLICY_ENDPOINT: emptyString(zod.string().url().optional()), TOKENS_ENDPOINT: zod.string().url(), - EMAILS_ENDPOINT: emptyString(zod.string().url().optional()), - WEBHOOKS_ENDPOINT: zod.string().url(), SCHEMA_ENDPOINT: zod.string().url(), AUTH_ORGANIZATION_OIDC: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), AUTH_REQUIRE_EMAIL_VERIFICATION: emptyString( @@ -380,8 +378,6 @@ export const env = { endpoint: base.SCHEMA_POLICY_ENDPOINT, } : null, - emails: base.EMAILS_ENDPOINT ? { endpoint: base.EMAILS_ENDPOINT } : null, - webhooks: { endpoint: base.WEBHOOKS_ENDPOINT }, schema: { endpoint: base.SCHEMA_ENDPOINT }, }, http: { diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index c54987f9dbd..0ea1d106b13 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -298,10 +298,6 @@ export async function main() { commerce: { endpoint: env.hiveServices.commerce ? env.hiveServices.commerce.endpoint : null, }, - emailsEndpoint: env.hiveServices.emails ? env.hiveServices.emails.endpoint : undefined, - webhooks: { - endpoint: env.hiveServices.webhooks.endpoint, - }, schemaService: { endpoint: env.hiveServices.schema.endpoint, }, diff --git a/packages/services/workflows/package.json b/packages/services/workflows/package.json index 213112c28d4..93d451f34b1 100644 --- a/packages/services/workflows/package.json +++ b/packages/services/workflows/package.json @@ -12,6 +12,7 @@ "@graphql-hive/logger": "1.0.9", "@hive/service-common": "workspace:*", "@hive/storage": "workspace:*", + "@sentry/node": "7.120.2", "graphile-worker": "0.16.6", "nodemailer": "7.0.11", "sendmail": "1.6.1", diff --git a/packages/services/workflows/src/heartbeat.ts b/packages/services/workflows/src/heartbeat.ts index 86358a47973..acf2b7ac95a 100644 --- a/packages/services/workflows/src/heartbeat.ts +++ b/packages/services/workflows/src/heartbeat.ts @@ -23,5 +23,5 @@ export async function startHeartbeat(logger: Logger) { setTimeout(writeHeartbeat, intervalMs).unref(); }; - writeHeartbeat(); + await writeHeartbeat(); } diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index 3945da3cff2..7deac33be40 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -56,7 +56,7 @@ const stopHttpHeartbeat = env.httpHeartbeat }) : null; -startHeartbeat(logger); +await startHeartbeat(logger); const context: Context = { logger, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad22361878d..d13e462056a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -776,6 +776,9 @@ importers: '@hive/usage-ingestor': specifier: workspace:* version: link:../usage-ingestor + '@hive/workflows': + specifier: workspace:* + version: link:../workflows '@nodesecure/i18n': specifier: ^4.0.1 version: 4.0.1 @@ -1004,6 +1007,9 @@ importers: '@hive/storage': specifier: workspace:* version: link:../storage + '@hive/workflows': + specifier: workspace:* + version: link:../workflows '@sentry/node': specifier: 7.120.2 version: 7.120.2 @@ -1314,6 +1320,9 @@ importers: '@hive/storage': specifier: workspace:* version: link:../storage + '@hive/workflows': + specifier: workspace:* + version: link:../workflows '@sentry/integrations': specifier: 7.114.0 version: 7.114.0 @@ -1720,6 +1729,9 @@ importers: '@hive/storage': specifier: workspace:* version: link:../storage + '@sentry/node': + specifier: 7.120.2 + version: 7.120.2 graphile-worker: specifier: 0.16.6 version: 0.16.6(typescript@5.7.3) @@ -19833,8 +19845,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19986,11 +19998,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0': + '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20029,6 +20041,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -20248,11 +20261,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': + '@aws-sdk/client-sts@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20291,7 +20304,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20523,7 +20535,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20770,7 +20782,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -21159,7 +21171,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 From 26b99516fbe72f1c1ae23cc6a1c206ffce742ea0 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 10 Dec 2025 23:03:12 +0100 Subject: [PATCH 21/37] node ftw --- packages/services/workflows/src/heartbeat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/workflows/src/heartbeat.ts b/packages/services/workflows/src/heartbeat.ts index acf2b7ac95a..2dc50f6b874 100644 --- a/packages/services/workflows/src/heartbeat.ts +++ b/packages/services/workflows/src/heartbeat.ts @@ -1,5 +1,5 @@ import fs from 'node:fs/promises'; -import path from 'path'; +import path from 'node:path'; import { Logger } from '@graphql-hive/logger'; /** Write latest date to filesystem for docker health check */ From 293d17dc11ccc3903fd8ad87cce5fad658dde1de Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 11 Dec 2025 16:15:58 +0100 Subject: [PATCH 22/37] emails and healthcheck http --- deployment/services/workflows.ts | 7 ++- docker/docker-compose.community.yml | 3 +- docker/docker.hcl | 4 +- integration-tests/testkit/emails.ts | 2 +- integration-tests/testkit/utils.ts | 1 + .../services/service-common/src/fastify.ts | 26 ++++++---- packages/services/workflows/src/heartbeat.ts | 27 ---------- packages/services/workflows/src/index.ts | 52 +++++++++++++++++-- 8 files changed, 73 insertions(+), 49 deletions(-) delete mode 100644 packages/services/workflows/src/heartbeat.ts diff --git a/deployment/services/workflows.ts b/deployment/services/workflows.ts index bfcc60e23d8..fded73ea2b6 100644 --- a/deployment/services/workflows.ts +++ b/deployment/services/workflows.ts @@ -53,10 +53,9 @@ export function deployWorkflows({ : '', LOG_JSON: '1', }, - // TODO: do I really need to add HTTP for these? :ok: - // readinessProbe: '/_readiness', - // livenessProbe: '/_health', - // startupProbe: '/_health', + readinessProbe: '/_readiness', + livenessProbe: '/_health', + startupProbe: '/_health', exposesMetrics: true, image, replicas: environment.podsConfig.general.replicas, diff --git a/docker/docker-compose.community.yml b/docker/docker-compose.community.yml index e4ec368e857..1a63e938e09 100644 --- a/docker/docker-compose.community.yml +++ b/docker/docker-compose.community.yml @@ -253,7 +253,7 @@ services: - 'stack' environment: NODE_ENV: production - PORT: 3012 + PORT: 3013 LOG_LEVEL: '${LOG_LEVEL:-debug}' OPENTELEMETRY_COLLECTOR_ENDPOINT: '${OPENTELEMETRY_COLLECTOR_ENDPOINT:-}' SENTRY: '${SENTRY:-0}' @@ -352,6 +352,7 @@ services: condition: service_healthy environment: NODE_ENV: production + PORT: 3012 POSTGRES_HOST: db POSTGRES_PORT: 5432 POSTGRES_DB: '${POSTGRES_DB}' diff --git a/docker/docker.hcl b/docker/docker.hcl index 927bc2f7c60..2aa5a899d76 100644 --- a/docker/docker.hcl +++ b/docker/docker.hcl @@ -326,8 +326,8 @@ target "workflows" { SERVICE_DIR_NAME = "@hive/workflows" IMAGE_TITLE = "graphql-hive/workflows" IMAGE_DESCRIPTION = "The workflow service of the GraphQL Hive project." - PORT = "3005" - HEALTHCHECK_CMD = "test $(($(date +%s) - $(cat /tmp/hive_worker_heartbeat))) -lt 60 || exit 1" + PORT = "3013" + HEALTHCHECK_CMD = "wget --spider -q http://127.0.0.1:$${PORT}/_readiness" } tags = [ local_image_tag("workflows"), diff --git a/integration-tests/testkit/emails.ts b/integration-tests/testkit/emails.ts index 419a1cb9539..c15e5d385ab 100644 --- a/integration-tests/testkit/emails.ts +++ b/integration-tests/testkit/emails.ts @@ -7,7 +7,7 @@ export interface Email { } export async function history(): Promise { - const emailsAddress = await getServiceHost('emails', 3011); + const emailsAddress = await getServiceHost('workflows', 3013); const response = await fetch(`http://${emailsAddress}/_history`, { method: 'GET', diff --git a/integration-tests/testkit/utils.ts b/integration-tests/testkit/utils.ts index ff6a55cef35..0b614ac03de 100644 --- a/integration-tests/testkit/utils.ts +++ b/integration-tests/testkit/utils.ts @@ -21,6 +21,7 @@ const LOCAL_SERVICES = { external_composition: 3012, mock_server: 3042, 'otel-collector': 4318, + workflows: 3013, } as const; export type KnownServices = keyof typeof LOCAL_SERVICES; diff --git a/packages/services/service-common/src/fastify.ts b/packages/services/service-common/src/fastify.ts index a1ac2be803e..a3e9a0c7be9 100644 --- a/packages/services/service-common/src/fastify.ts +++ b/packages/services/service-common/src/fastify.ts @@ -1,5 +1,6 @@ -import { fastify } from 'fastify'; +import { fastify, type FastifyBaseLogger } from 'fastify'; import cors from '@fastify/cors'; +import { Logger } from '@graphql-hive/logger'; import * as Sentry from '@sentry/node'; import { useHTTPErrorHandler } from './http-error-handler'; import { useRequestLogging } from './request-logs'; @@ -9,20 +10,25 @@ export type { FastifyBaseLogger, FastifyRequest, FastifyReply } from 'fastify'; export async function createServer(options: { sentryErrorHandler: boolean; name: string; - log: { - requests: boolean; - level: string; - }; + log: + | { + requests: boolean; + level: string; + } + | Logger; cors?: boolean; bodyLimit?: number; }) { const server = fastify({ disableRequestLogging: true, bodyLimit: options.bodyLimit ?? 30e6, // 30mb by default - logger: { - level: options.log.level, - redact: ['request.options', 'options', 'request.headers.authorization'], - }, + logger: + options.log instanceof Logger + ? (options.log as unknown as FastifyBaseLogger) + : { + level: options.log.level, + redact: ['request.options', 'options', 'request.headers.authorization'], + }, maxParamLength: 5000, requestIdHeader: 'x-request-id', trustProxy: true, @@ -44,7 +50,7 @@ export async function createServer(options: { await useHTTPErrorHandler(server, options.sentryErrorHandler); - if (options.log.requests) { + if (options.log instanceof Logger === false && options.log.requests) { await useRequestLogging(server); } diff --git a/packages/services/workflows/src/heartbeat.ts b/packages/services/workflows/src/heartbeat.ts deleted file mode 100644 index 2dc50f6b874..00000000000 --- a/packages/services/workflows/src/heartbeat.ts +++ /dev/null @@ -1,27 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { Logger } from '@graphql-hive/logger'; - -/** Write latest date to filesystem for docker health check */ -export async function startHeartbeat(logger: Logger) { - const file = '/tmp/hive_worker_heartbeat'; - const intervalMs = 10_000; - - const dir = path.dirname(file); - - // Make sure directory exists - await fs.mkdir(dir, { recursive: true }); - - const writeHeartbeat = async () => { - try { - const timestamp = Math.floor(Date.now() / 1000).toString(); - await fs.writeFile(file, timestamp); - } catch (errror) { - logger.error({ errror }, 'Heartbeat write failed:'); - } - - setTimeout(writeHeartbeat, intervalMs).unref(); - }; - - await writeHeartbeat(); -} diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index 7deac33be40..fae40d86202 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -2,11 +2,16 @@ import { hostname } from 'node:os'; import { run } from 'graphile-worker'; import { createPool } from 'slonik'; import { Logger } from '@graphql-hive/logger'; -import { registerShutdown, startHeartbeats, startMetrics } from '@hive/service-common'; +import { + createServer, + registerShutdown, + reportReadiness, + startHeartbeats, + startMetrics, +} from '@hive/service-common'; import * as Sentry from '@sentry/node'; import { Context } from './context.js'; import { env } from './environment.js'; -import { startHeartbeat } from './heartbeat.js'; import { createEmailProvider } from './lib/emails/providers.js'; import { bridgeFastifyLogger, bridgeGraphileLogger } from './logger.js'; import { createTaskEventEmitter } from './task-events.js'; @@ -56,8 +61,6 @@ const stopHttpHeartbeat = env.httpHeartbeat }) : null; -await startHeartbeat(logger); - const context: Context = { logger, email: createEmailProvider(env.email.provider, env.email.emailFrom), @@ -65,6 +68,44 @@ const context: Context = { requestBroker: env.requestBroker, }; +const server = await createServer({ + sentryErrorHandler: !!env.sentry, + name: 'workflows', + log: logger, +}); + +server.route({ + method: ['GET', 'HEAD'], + url: '/_health', + handler(_req, res) { + void res.status(200).send(); + }, +}); + +server.route({ + method: ['GET', 'HEAD'], + url: '/_readiness', + handler(_, res) { + reportReadiness(true); + void res.status(200).send(); + }, +}); + +if (context.email.id === 'mock') { + server.route({ + method: ['GET'], + url: '/_history', + handler(_, res) { + void res.status(200).send(context.email.history); + }, + }); +} + +await server.listen({ + port: env.http.port, + host: '::', +}); + const shutdownMetrics = env.prometheus ? await startMetrics(env.prometheus.labels.instance, env.prometheus.port) : null; @@ -98,6 +139,9 @@ registerShutdown({ stopHttpHeartbeat(); logger.info('HTTP heartbeat stopped'); } + logger.info('Stopping HTTP server'); + await server.close(); + logger.info('HTTP server stopped'); } catch (error: unknown) { logger.error({ error }, 'Unepected error occured'); process.exit(1); From 6df7a19c5fc43990e7c4ef405d0b705015897fc0 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 11 Dec 2025 17:06:50 +0100 Subject: [PATCH 23/37] fix --- .../services/service-common/src/fastify.ts | 36 ++++++++++++++++++- packages/services/workflows/src/dev.ts | 15 ++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 packages/services/workflows/src/dev.ts diff --git a/packages/services/service-common/src/fastify.ts b/packages/services/service-common/src/fastify.ts index a3e9a0c7be9..67ec154b25f 100644 --- a/packages/services/service-common/src/fastify.ts +++ b/packages/services/service-common/src/fastify.ts @@ -7,6 +7,40 @@ import { useRequestLogging } from './request-logs'; export type { FastifyBaseLogger, FastifyRequest, FastifyReply } from 'fastify'; +function bridgeFastifyLogger(logger: Logger): FastifyBaseLogger { + return { + debug(...args: Array) { + // @ts-expect-error + logger.debug(...args); + }, + error(...args: Array) { + // @ts-expect-error + logger.error(...args); + }, + fatal(...args: Array) { + // @ts-expect-error + logger.error(...args); + }, + trace(...args: Array) { + // @ts-expect-error + logger.trace(...args); + }, + info(...args: Array) { + // @ts-expect-error + logger.info(...args); + }, + warn(...args: Array) { + // @ts-expect-error + logger.warn(...args); + }, + child() { + return this; + }, + level: logger.level === false ? 'silent' : logger.level, + silent() {}, + }; +} + export async function createServer(options: { sentryErrorHandler: boolean; name: string; @@ -24,7 +58,7 @@ export async function createServer(options: { bodyLimit: options.bodyLimit ?? 30e6, // 30mb by default logger: options.log instanceof Logger - ? (options.log as unknown as FastifyBaseLogger) + ? bridgeFastifyLogger(options.log) : { level: options.log.level, redact: ['request.options', 'options', 'request.headers.authorization'], diff --git a/packages/services/workflows/src/dev.ts b/packages/services/workflows/src/dev.ts new file mode 100644 index 00000000000..26e40143bf1 --- /dev/null +++ b/packages/services/workflows/src/dev.ts @@ -0,0 +1,15 @@ +import { config } from 'dotenv'; + +config({ + debug: true, + encoding: 'utf8', +}); + +await import('./index.js'); + +// Not having this caused hell +process.stdout.on('error', function (err) { + if (err.code == 'EPIPE') { + process.exit(0); + } +}); From aa3ba4a662811ba4153602a3856a0db6ffd74e55 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 11 Dec 2025 17:43:09 +0100 Subject: [PATCH 24/37] fix test --- packages/services/workflows/src/environment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index b954f368afd..56663618a83 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -21,7 +21,7 @@ const emptyString = (input: T) => { }; const EnvironmentModel = zod.object({ - PORT: emptyString(NumberFromString.optional()), + PORT: emptyString(NumberFromString.optional()).default(3013), ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()), From 5b4b4750073a53b5c029f90e3c4d6be9a0525a9d Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 11 Dec 2025 17:45:33 +0100 Subject: [PATCH 25/37] fix deps --- packages/services/service-common/package.json | 1 + .../services/service-common/src/fastify.ts | 12 +++++------ pnpm-lock.yaml | 20 +++++++++---------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/services/service-common/package.json b/packages/services/service-common/package.json index 1ab68ba698f..a1824cd67ad 100644 --- a/packages/services/service-common/package.json +++ b/packages/services/service-common/package.json @@ -10,6 +10,7 @@ }, "devDependencies": { "@fastify/cors": "9.0.1", + "@graphql-hive/logger": "1.0.9", "@opentelemetry/api": "1.9.0", "@opentelemetry/auto-instrumentations-node": "0.64.1", "@opentelemetry/context-async-hooks": "1.30.0", diff --git a/packages/services/service-common/src/fastify.ts b/packages/services/service-common/src/fastify.ts index 67ec154b25f..f613d0062c3 100644 --- a/packages/services/service-common/src/fastify.ts +++ b/packages/services/service-common/src/fastify.ts @@ -10,27 +10,27 @@ export type { FastifyBaseLogger, FastifyRequest, FastifyReply } from 'fastify'; function bridgeFastifyLogger(logger: Logger): FastifyBaseLogger { return { debug(...args: Array) { - // @ts-expect-error + // @ts-expect-error logger.debug.apply raised an exception :( logger.debug(...args); }, error(...args: Array) { - // @ts-expect-error + // @ts-expect-error logger.debug.apply raised an exception :( logger.error(...args); }, fatal(...args: Array) { - // @ts-expect-error + // @ts-expect-error logger.debug.apply raised an exception :( logger.error(...args); }, trace(...args: Array) { - // @ts-expect-error + // @ts-expect-error logger.debug.apply raised an exception :( logger.trace(...args); }, info(...args: Array) { - // @ts-expect-error + // @ts-expect-error logger.debug.apply raised an exception :( logger.info(...args); }, warn(...args: Array) { - // @ts-expect-error + // @ts-expect-error logger.debug.apply raised an exception :( logger.warn(...args); }, child() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d13e462056a..c90f5dc1a16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19845,8 +19845,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19998,11 +19998,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': + '@aws-sdk/client-sso-oidc@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20041,7 +20041,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -20261,11 +20260,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0': + '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20304,6 +20303,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20535,7 +20535,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20782,7 +20782,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -21171,7 +21171,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 From 02db1866270f9913ed2dd201866206c6ba7ee8ef Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 11 Dec 2025 18:28:33 +0100 Subject: [PATCH 26/37] lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c90f5dc1a16..04166714c63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1396,6 +1396,9 @@ importers: '@fastify/cors': specifier: 9.0.1 version: 9.0.1 + '@graphql-hive/logger': + specifier: 1.0.9 + version: 1.0.9 '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 From e1f9af93e826ccb954dfe2ed4197a70ee04effda Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 11 Dec 2025 19:01:52 +0100 Subject: [PATCH 27/37] lol --- packages/services/workflows/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/services/workflows/package.json b/packages/services/workflows/package.json index 93d451f34b1..6fef5034687 100644 --- a/packages/services/workflows/package.json +++ b/packages/services/workflows/package.json @@ -13,6 +13,7 @@ "@hive/service-common": "workspace:*", "@hive/storage": "workspace:*", "@sentry/node": "7.120.2", + "dotenv": "16.4.7", "graphile-worker": "0.16.6", "nodemailer": "7.0.11", "sendmail": "1.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04166714c63..d6a9847489d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1735,6 +1735,9 @@ importers: '@sentry/node': specifier: 7.120.2 version: 7.120.2 + dotenv: + specifier: 16.4.7 + version: 16.4.7 graphile-worker: specifier: 0.16.6 version: 0.16.6(typescript@5.7.3) From c5f2634156b0cf50769826eadba2060664b3e1ce Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Thu, 11 Dec 2025 19:07:05 +0100 Subject: [PATCH 28/37] change port --- docker/docker-compose.community.yml | 2 +- integration-tests/docker-compose.integration.yaml | 7 +++++++ integration-tests/testkit/emails.ts | 2 +- integration-tests/testkit/utils.ts | 2 +- packages/services/workflows/src/environment.ts | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docker/docker-compose.community.yml b/docker/docker-compose.community.yml index 1a63e938e09..56b288f651b 100644 --- a/docker/docker-compose.community.yml +++ b/docker/docker-compose.community.yml @@ -352,7 +352,7 @@ services: condition: service_healthy environment: NODE_ENV: production - PORT: 3012 + PORT: 3014 POSTGRES_HOST: db POSTGRES_PORT: 5432 POSTGRES_DB: '${POSTGRES_DB}' diff --git a/integration-tests/docker-compose.integration.yaml b/integration-tests/docker-compose.integration.yaml index 3818f854651..9cf4a5ee0b3 100644 --- a/integration-tests/docker-compose.integration.yaml +++ b/integration-tests/docker-compose.integration.yaml @@ -245,6 +245,13 @@ services: ports: - '3011:3011' + workflows: + environment: + EMAIL_PROVIDER: '${EMAIL_PROVIDER}' + LOG_LEVEL: debug + ports: + - '3014:3014' + schema: environment: LOG_LEVEL: debug diff --git a/integration-tests/testkit/emails.ts b/integration-tests/testkit/emails.ts index c15e5d385ab..265ee89bdfa 100644 --- a/integration-tests/testkit/emails.ts +++ b/integration-tests/testkit/emails.ts @@ -7,7 +7,7 @@ export interface Email { } export async function history(): Promise { - const emailsAddress = await getServiceHost('workflows', 3013); + const emailsAddress = await getServiceHost('workflows', 3014); const response = await fetch(`http://${emailsAddress}/_history`, { method: 'GET', diff --git a/integration-tests/testkit/utils.ts b/integration-tests/testkit/utils.ts index 0b614ac03de..8fd3aa7bbc8 100644 --- a/integration-tests/testkit/utils.ts +++ b/integration-tests/testkit/utils.ts @@ -21,7 +21,7 @@ const LOCAL_SERVICES = { external_composition: 3012, mock_server: 3042, 'otel-collector': 4318, - workflows: 3013, + workflows: 3014, } as const; export type KnownServices = keyof typeof LOCAL_SERVICES; diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index 56663618a83..c8b1904fdbc 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -21,7 +21,7 @@ const emptyString = (input: T) => { }; const EnvironmentModel = zod.object({ - PORT: emptyString(NumberFromString.optional()).default(3013), + PORT: emptyString(NumberFromString.optional()).default(3014), ENVIRONMENT: emptyString(zod.string().optional()), RELEASE: emptyString(zod.string().optional()), HEARTBEAT_ENDPOINT: emptyString(zod.string().url().optional()), From 6d05ecf22db4f073bca5b169ede2151f04a6e466 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 12 Dec 2025 10:05:03 +0100 Subject: [PATCH 29/37] better --- .../services/service-common/src/fastify.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/services/service-common/src/fastify.ts b/packages/services/service-common/src/fastify.ts index f613d0062c3..fc2a0ca524f 100644 --- a/packages/services/service-common/src/fastify.ts +++ b/packages/services/service-common/src/fastify.ts @@ -10,28 +10,22 @@ export type { FastifyBaseLogger, FastifyRequest, FastifyReply } from 'fastify'; function bridgeFastifyLogger(logger: Logger): FastifyBaseLogger { return { debug(...args: Array) { - // @ts-expect-error logger.debug.apply raised an exception :( - logger.debug(...args); + logger.debug.apply(logger, args as any); }, error(...args: Array) { - // @ts-expect-error logger.debug.apply raised an exception :( - logger.error(...args); + logger.error.apply(logger, args as any); }, fatal(...args: Array) { - // @ts-expect-error logger.debug.apply raised an exception :( - logger.error(...args); + logger.error.apply(logger, args as any); }, trace(...args: Array) { - // @ts-expect-error logger.debug.apply raised an exception :( - logger.trace(...args); + logger.trace.apply(logger, args as any); }, info(...args: Array) { - // @ts-expect-error logger.debug.apply raised an exception :( - logger.info(...args); + logger.info.apply(logger, args as any); }, warn(...args: Array) { - // @ts-expect-error logger.debug.apply raised an exception :( - logger.warn(...args); + logger.warn.apply(logger, args as any); }, child() { return this; From 3af8e7c36de71f072106635487f4e38e607a54ca Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 12 Dec 2025 13:23:31 +0100 Subject: [PATCH 30/37] fix tests --- integration-tests/testkit/emails.ts | 10 ++- .../tests/api/rate-limit/emails.spec.ts | 22 +++--- packages/migrations/src/run-pg-migrations.ts | 1 + .../commerce/src/rate-limit/emails.ts | 66 +++++++++++------ .../commerce/src/rate-limit/limiter.ts | 1 + .../services/service-common/src/fastify.ts | 10 ++- packages/services/workflows/src/kit.ts | 74 +++++++++++++++++-- .../src/tasks/usage-rate-limit-warning.ts | 2 +- pnpm-lock.yaml | 7 +- 9 files changed, 146 insertions(+), 47 deletions(-) diff --git a/integration-tests/testkit/emails.ts b/integration-tests/testkit/emails.ts index 265ee89bdfa..c4807bd9881 100644 --- a/integration-tests/testkit/emails.ts +++ b/integration-tests/testkit/emails.ts @@ -6,7 +6,7 @@ export interface Email { body: string; } -export async function history(): Promise { +export async function history(forEmail?: string): Promise { const emailsAddress = await getServiceHost('workflows', 3014); const response = await fetch(`http://${emailsAddress}/_history`, { @@ -17,5 +17,11 @@ export async function history(): Promise { }, }); - return response.json(); + const result: Email[] = await response.json(); + + if (!forEmail) { + return result; + } + + return result.filter(result => result.to === forEmail); } diff --git a/integration-tests/tests/api/rate-limit/emails.spec.ts b/integration-tests/tests/api/rate-limit/emails.spec.ts index e8e76de6410..ee909869240 100644 --- a/integration-tests/tests/api/rate-limit/emails.spec.ts +++ b/integration-tests/tests/api/rate-limit/emails.spec.ts @@ -12,7 +12,7 @@ function filterEmailsByOrg(orgSlug: string, emails: emails.Email[]) { })); } -test('rate limit approaching and reached for organization', async () => { +test.only('rate limit approaching and reached for organization', async () => { const { createOrg, ownerToken, ownerEmail } = await initSeed().createOwner(); const { createProject, organization } = await createOrg(); const { createTargetAccessToken, waitForRequestsCollected } = await createProject( @@ -54,12 +54,16 @@ test('rate limit approaching and reached for organization', async () => { return filterEmailsByOrg(organization.slug, sent)?.length === 1; }); - let sent = await emails.history(); - expect(sent).toContainEqual({ - to: ownerEmail, - subject: `${organization.slug} is approaching its rate limit`, - body: expect.any(String), - }); + let sent = await emails.history(ownerEmail); + + expect(sent).toEqual([ + { + to: ownerEmail, + subject: `${organization.slug} is approaching its rate limit`, + body: expect.any(String), + }, + ]); + expect(filterEmailsByOrg(organization.slug, sent)).toHaveLength(1); // Collect operations and check for rate-limit reached @@ -70,11 +74,11 @@ test('rate limit approaching and reached for organization', async () => { // wait for the quota email to send... await pollFor(async () => { - let sent = await emails.history(); + let sent = await emails.history(ownerEmail); return filterEmailsByOrg(organization.slug, sent)?.length === 2; }); - sent = await emails.history(); + sent = await emails.history(ownerEmail); expect(sent).toContainEqual({ to: ownerEmail, diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 84ecd1f543e..d410aa96902 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -170,5 +170,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2025.10.16T00-00-00.schema-log-by-commit-ordered'), await import('./actions/2025.10.17T00-00-00.project-access-tokens'), await import('./actions/2025.11.12T00-00-00.granular-oidc-role-permissions'), + await import('./actions/2025.12.12T00-00-00.workflows-deduplication'), ], }); diff --git a/packages/services/commerce/src/rate-limit/emails.ts b/packages/services/commerce/src/rate-limit/emails.ts index b71766e2494..6006f5b743c 100644 --- a/packages/services/commerce/src/rate-limit/emails.ts +++ b/packages/services/commerce/src/rate-limit/emails.ts @@ -29,18 +29,27 @@ export function createEmailScheduler(taskScheduler: TaskScheduler) { }; }) { return scheduledEmails.push( - taskScheduler.scheduleTask(UsageRateLimitExceededTask, { - email: input.organization.email, - organizationId: input.organization.id, - organizationName: input.organization.name, - limit: input.usage.quota, - currentUsage: input.usage.current, - startDate: input.period.start, - endDate: input.period.end, - subscriptionManagementLink: `${env.hiveServices.webAppUrl}/${ - input.organization.slug - }/view/subscription`, - }), + taskScheduler.scheduleTask( + UsageRateLimitExceededTask, + { + email: input.organization.email, + organizationId: input.organization.id, + organizationName: input.organization.name, + limit: input.usage.quota, + currentUsage: input.usage.current, + startDate: input.period.start, + endDate: input.period.end, + subscriptionManagementLink: `${env.hiveServices.webAppUrl}/${ + input.organization.slug + }/view/subscription`, + }, + { + dedupe: { + key: p => p.organizationId, + ttl: 1000 * 60 * 60 * 24 * 32, + }, + }, + ), ); }, @@ -61,18 +70,27 @@ export function createEmailScheduler(taskScheduler: TaskScheduler) { }; }) { return scheduledEmails.push( - taskScheduler.scheduleTask(UsageRateLimitWarningTask, { - email: input.organization.email, - organizationId: input.organization.id, - organizationName: input.organization.name, - limit: input.usage.quota, - currentUsage: input.usage.current, - startDate: input.period.start, - endDate: input.period.end, - subscriptionManagementLink: `${env.hiveServices.webAppUrl}/${ - input.organization.slug - }/view/subscription`, - }), + taskScheduler.scheduleTask( + UsageRateLimitWarningTask, + { + email: input.organization.email, + organizationId: input.organization.id, + organizationName: input.organization.name, + limit: input.usage.quota, + currentUsage: input.usage.current, + startDate: input.period.start, + endDate: input.period.end, + subscriptionManagementLink: `${env.hiveServices.webAppUrl}/${ + input.organization.slug + }/view/subscription`, + }, + { + dedupe: { + key: p => p.organizationId, + ttl: 1000 * 60 * 60 * 24 * 32, + }, + }, + ), ); }, }; diff --git a/packages/services/commerce/src/rate-limit/limiter.ts b/packages/services/commerce/src/rate-limit/limiter.ts index 01830750a23..77785b9beb5 100644 --- a/packages/services/commerce/src/rate-limit/limiter.ts +++ b/packages/services/commerce/src/rate-limit/limiter.ts @@ -183,6 +183,7 @@ export function createRateLimiter(config: { function getOrganizationFromCache(targetId: string) { const orgId = targetIdToOrgLookup.get(targetId); + return orgId ? cachedResult.get(orgId) : undefined; } diff --git a/packages/services/service-common/src/fastify.ts b/packages/services/service-common/src/fastify.ts index fc2a0ca524f..2e1342f16a4 100644 --- a/packages/services/service-common/src/fastify.ts +++ b/packages/services/service-common/src/fastify.ts @@ -7,7 +7,11 @@ import { useRequestLogging } from './request-logs'; export type { FastifyBaseLogger, FastifyRequest, FastifyReply } from 'fastify'; -function bridgeFastifyLogger(logger: Logger): FastifyBaseLogger { +/* eslint-disable prefer-spread */ + +// Using spread causes typescript errors +// I prefer to disable eslint over having to use ts-ignore and ts not catching other errors. +function bridgeHiveLoggerToFastifyLogger(logger: Logger): FastifyBaseLogger { return { debug(...args: Array) { logger.debug.apply(logger, args as any); @@ -35,6 +39,8 @@ function bridgeFastifyLogger(logger: Logger): FastifyBaseLogger { }; } +/* eslint-enable prefer-spread */ + export async function createServer(options: { sentryErrorHandler: boolean; name: string; @@ -52,7 +58,7 @@ export async function createServer(options: { bodyLimit: options.bodyLimit ?? 30e6, // 30mb by default logger: options.log instanceof Logger - ? bridgeFastifyLogger(options.log) + ? bridgeHiveLoggerToFastifyLogger(options.log) : { level: options.log.level, redact: ['request.options', 'options', 'request.headers.authorization'], diff --git a/packages/services/workflows/src/kit.ts b/packages/services/workflows/src/kit.ts index 2f47de61c79..1fe00c4a11e 100644 --- a/packages/services/workflows/src/kit.ts +++ b/packages/services/workflows/src/kit.ts @@ -1,8 +1,9 @@ import { makeWorkerUtils, WorkerUtils, type JobHelpers, type Task } from 'graphile-worker'; import type { Pool } from 'pg'; import { z } from 'zod'; -import type { Logger } from '@graphql-hive/logger'; +import { Logger } from '@graphql-hive/logger'; import type { Context } from './context'; +import { bridgeGraphileLogger } from './logger'; export type TaskDefinition = { name: TName; @@ -70,9 +71,13 @@ export function implementTask( */ export class TaskScheduler { tools: Promise; - constructor(pgPool: Pool) { + constructor( + pgPool: Pool, + private logger: Logger = new Logger(), + ) { this.tools = makeWorkerUtils({ pgPool, + logger: bridgeGraphileLogger(logger), }); } @@ -81,14 +86,71 @@ export class TaskScheduler { payload: TPayload, opts?: { requestId?: string; + /** Ensures the task is scheduled only once. */ + dedupe?: { + /** dedupe key for this task */ + key: string | ((payload: TPayload) => string); + /** how long should the task be de-duped in milliseconds */ + ttl: number; + }; }, ) { - await ( - await this.tools - ).addJob(taskDefinition.name, { + this.logger.info( + { + 'job.taskId': taskDefinition.name, + }, + 'attempt enqueue task', + ); + + const input = taskDefinition.schema.parse(payload); + + const tools = await this.tools; + + if (opts?.dedupe) { + const dedupeKey = + typeof opts.dedupe.key === 'string' ? opts.dedupe.key : opts.dedupe.key(payload); + const expiresAt = new Date(new Date().getTime() + opts.dedupe.ttl).toISOString(); + + const shouldSkip = await tools.withPgClient(async client => { + const result = await client.query( + ` + INSERT INTO "graphile_worker_deduplication" ("task_name", "dedupe_key", "expires_at") + VALUES($1, $2, $3) + ON CONFLICT ("task_name", "dedupe_key") + DO + UPDATE SET "expires_at" = EXCLUDED.expires_at + WHERE "graphile_worker_deduplication"."expires_at" < NOW() + RETURNING xmax = 0 AS "inserted" + `, + [taskDefinition.name, dedupeKey, expiresAt], + ); + + return result.rows.length === 0; + }); + + if (shouldSkip) { + this.logger.info( + { + 'job.taskId': taskDefinition.name, + }, + 'enqueue skipped due to dedupe', + ); + return; + } + } + + const job = await tools.addJob(taskDefinition.name, { requestId: opts?.requestId, - input: taskDefinition.schema.parse(payload), + input, }); + + this.logger.info( + { + 'job.taskId': taskDefinition.name, + 'job.id': job.id, + }, + 'task enqueued.', + ); } async dispose() { diff --git a/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts b/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts index d0876caeca8..b0309b6b7a4 100644 --- a/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts +++ b/packages/services/workflows/src/tasks/usage-rate-limit-warning.ts @@ -18,7 +18,7 @@ export const UsageRateLimitWarningTask = defineTask({ export const task = implementTask(UsageRateLimitWarningTask, async args => { await args.context.email.send({ - subject: `GraphQL-Hive operations quota for ${args.input.organizationName} exceeded`, + subject: `${args.input.organizationName} is approaching its rate limit`, to: args.input.email, body: renderRateLimitWarningEmail({ organizationName: args.input.organizationName, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6a9847489d..1c615d869b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1722,6 +1722,10 @@ importers: version: 3.25.76 packages/services/workflows: + dependencies: + dotenv: + specifier: 16.4.7 + version: 16.4.7 devDependencies: '@graphql-hive/logger': specifier: 1.0.9 @@ -1735,9 +1739,6 @@ importers: '@sentry/node': specifier: 7.120.2 version: 7.120.2 - dotenv: - specifier: 16.4.7 - version: 16.4.7 graphile-worker: specifier: 0.16.6 version: 0.16.6(typescript@5.7.3) From d2c9988545d24b5ca3394d52fff1f2808e405838 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 12 Dec 2025 13:24:47 +0100 Subject: [PATCH 31/37] oops --- .../2025.12.12T00-00-00.workflows-deduplication.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/migrations/src/actions/2025.12.12T00-00-00.workflows-deduplication.ts diff --git a/packages/migrations/src/actions/2025.12.12T00-00-00.workflows-deduplication.ts b/packages/migrations/src/actions/2025.12.12T00-00-00.workflows-deduplication.ts new file mode 100644 index 00000000000..8b7b9899555 --- /dev/null +++ b/packages/migrations/src/actions/2025.12.12T00-00-00.workflows-deduplication.ts @@ -0,0 +1,13 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2025.12.12T00-00-00.workflows-deduplication.ts', + run: ({ sql }) => sql` + CREATE TABLE "graphile_worker_deduplication" ( + "task_name" text NOT NULL, + "dedupe_key" text NOT NULL, + "expires_at" timestamptz NOT NULL, + CONSTRAINT "dedupe_pk" PRIMARY KEY ("task_name", "dedupe_key") + ); + `, +} satisfies MigrationExecutor; From 9eb9d2090a6a3b2d801f1fa81d84011a81738fe0 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 12 Dec 2025 13:49:23 +0100 Subject: [PATCH 32/37] ffs --- pnpm-lock.yaml | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c615d869b3..63adf55deb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1722,10 +1722,6 @@ importers: version: 3.25.76 packages/services/workflows: - dependencies: - dotenv: - specifier: 16.4.7 - version: 16.4.7 devDependencies: '@graphql-hive/logger': specifier: 1.0.9 @@ -1739,6 +1735,9 @@ importers: '@sentry/node': specifier: 7.120.2 version: 7.120.2 + dotenv: + specifier: 16.4.7 + version: 16.4.7 graphile-worker: specifier: 0.16.6 version: 0.16.6(typescript@5.7.3) @@ -19852,8 +19851,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20005,11 +20004,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0': + '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20048,6 +20047,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -20267,11 +20267,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': + '@aws-sdk/client-sts@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20310,7 +20310,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20542,7 +20541,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20789,7 +20788,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -21178,7 +21177,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 From 6c973e9dbb76c2e5032de751a7566b51b8727ef7 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 12 Dec 2025 14:17:49 +0100 Subject: [PATCH 33/37] no only --- integration-tests/tests/api/rate-limit/emails.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/tests/api/rate-limit/emails.spec.ts b/integration-tests/tests/api/rate-limit/emails.spec.ts index ee909869240..581d07a951e 100644 --- a/integration-tests/tests/api/rate-limit/emails.spec.ts +++ b/integration-tests/tests/api/rate-limit/emails.spec.ts @@ -12,7 +12,7 @@ function filterEmailsByOrg(orgSlug: string, emails: emails.Email[]) { })); } -test.only('rate limit approaching and reached for organization', async () => { +test('rate limit approaching and reached for organization', async () => { const { createOrg, ownerToken, ownerEmail } = await initSeed().createOwner(); const { createProject, organization } = await createOrg(); const { createTargetAccessToken, waitForRequestsCollected } = await createProject( From 332d4fc9a0ddbbd5e5338d396c3e57f5b3d16b1e Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 12 Dec 2025 14:50:47 +0100 Subject: [PATCH 34/37] why tf did i do this --- docker/docker-compose.community.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.community.yml b/docker/docker-compose.community.yml index 56b288f651b..2774758532b 100644 --- a/docker/docker-compose.community.yml +++ b/docker/docker-compose.community.yml @@ -253,7 +253,7 @@ services: - 'stack' environment: NODE_ENV: production - PORT: 3013 + PORT: 3012 LOG_LEVEL: '${LOG_LEVEL:-debug}' OPENTELEMETRY_COLLECTOR_ENDPOINT: '${OPENTELEMETRY_COLLECTOR_ENDPOINT:-}' SENTRY: '${SENTRY:-0}' From 061d9553338158e2693c598a046d1b9be5a1373e Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 12 Dec 2025 17:32:24 +0100 Subject: [PATCH 35/37] reduce pg pressure for dedupe tasks --- packages/services/workflows/package.json | 1 + packages/services/workflows/src/kit.ts | 51 +++++++++++++++++------- pnpm-lock.yaml | 23 ++++++----- 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/packages/services/workflows/package.json b/packages/services/workflows/package.json index 6fef5034687..d474dcbffa8 100644 --- a/packages/services/workflows/package.json +++ b/packages/services/workflows/package.json @@ -13,6 +13,7 @@ "@hive/service-common": "workspace:*", "@hive/storage": "workspace:*", "@sentry/node": "7.120.2", + "bentocache": "1.1.0", "dotenv": "16.4.7", "graphile-worker": "0.16.6", "nodemailer": "7.0.11", diff --git a/packages/services/workflows/src/kit.ts b/packages/services/workflows/src/kit.ts index 1fe00c4a11e..76a06964463 100644 --- a/packages/services/workflows/src/kit.ts +++ b/packages/services/workflows/src/kit.ts @@ -1,3 +1,5 @@ +import { BentoCache, bentostore } from 'bentocache'; +import { memoryDriver } from 'bentocache/build/src/drivers/memory'; import { makeWorkerUtils, WorkerUtils, type JobHelpers, type Task } from 'graphile-worker'; import type { Pool } from 'pg'; import { z } from 'zod'; @@ -71,6 +73,8 @@ export function implementTask( */ export class TaskScheduler { tools: Promise; + cache: BentoCache<{ store: ReturnType }>; + constructor( pgPool: Pool, private logger: Logger = new Logger(), @@ -79,6 +83,17 @@ export class TaskScheduler { pgPool, logger: bridgeGraphileLogger(logger), }); + this.cache = new BentoCache({ + default: 'taskSchedule', + stores: { + taskSchedule: bentostore().useL1Layer( + memoryDriver({ + maxItems: 10_000, + prefix: 'bentocache:graphile_worker_deduplication', + }), + ), + }, + }); } async scheduleTask( @@ -111,21 +126,27 @@ export class TaskScheduler { typeof opts.dedupe.key === 'string' ? opts.dedupe.key : opts.dedupe.key(payload); const expiresAt = new Date(new Date().getTime() + opts.dedupe.ttl).toISOString(); - const shouldSkip = await tools.withPgClient(async client => { - const result = await client.query( - ` - INSERT INTO "graphile_worker_deduplication" ("task_name", "dedupe_key", "expires_at") - VALUES($1, $2, $3) - ON CONFLICT ("task_name", "dedupe_key") - DO - UPDATE SET "expires_at" = EXCLUDED.expires_at - WHERE "graphile_worker_deduplication"."expires_at" < NOW() - RETURNING xmax = 0 AS "inserted" - `, - [taskDefinition.name, dedupeKey, expiresAt], - ); - - return result.rows.length === 0; + const shouldSkip = await this.cache.getOrSet({ + key: `${taskDefinition.name}:${dedupeKey}`, + ttl: opts.dedupe.ttl, + async factory() { + return await tools.withPgClient(async client => { + const result = await client.query( + ` + INSERT INTO "graphile_worker_deduplication" ("task_name", "dedupe_key", "expires_at") + VALUES($1, $2, $3) + ON CONFLICT ("task_name", "dedupe_key") + DO + UPDATE SET "expires_at" = EXCLUDED.expires_at + WHERE "graphile_worker_deduplication"."expires_at" < NOW() + RETURNING xmax = 0 AS "inserted" + `, + [taskDefinition.name, dedupeKey, expiresAt], + ); + + return result.rows.length === 0; + }); + }, }); if (shouldSkip) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63adf55deb8..6c03c824b2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1735,6 +1735,9 @@ importers: '@sentry/node': specifier: 7.120.2 version: 7.120.2 + bentocache: + specifier: 1.1.0 + version: 1.1.0(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.8.2) dotenv: specifier: 16.4.7 version: 16.4.7 @@ -19851,8 +19854,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20004,11 +20007,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': + '@aws-sdk/client-sso-oidc@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20047,7 +20050,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -20267,11 +20269,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0': + '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -20310,6 +20312,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20541,7 +20544,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20788,7 +20791,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0 + '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -21177,7 +21180,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 From 8cd9e1c0096e7176d6064d365a61ffe6a7f82cc6 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 12 Dec 2025 17:44:29 +0100 Subject: [PATCH 36/37] cleanup of dedupe keys lol --- ....12.12T00-00-00.workflows-deduplication.ts | 4 +++ packages/services/storage/src/db/types.ts | 7 ++++++ packages/services/workflows/src/index.ts | 3 +++ .../src/tasks/purge-expired-dedupe-keys.ts | 25 +++++++++++++++++++ 4 files changed, 39 insertions(+) create mode 100644 packages/services/workflows/src/tasks/purge-expired-dedupe-keys.ts diff --git a/packages/migrations/src/actions/2025.12.12T00-00-00.workflows-deduplication.ts b/packages/migrations/src/actions/2025.12.12T00-00-00.workflows-deduplication.ts index 8b7b9899555..0a0a413595e 100644 --- a/packages/migrations/src/actions/2025.12.12T00-00-00.workflows-deduplication.ts +++ b/packages/migrations/src/actions/2025.12.12T00-00-00.workflows-deduplication.ts @@ -9,5 +9,9 @@ export default { "expires_at" timestamptz NOT NULL, CONSTRAINT "dedupe_pk" PRIMARY KEY ("task_name", "dedupe_key") ); + + CREATE INDEX "graphile_worker_deduplication_expires_at_idx" + ON "graphile_worker_deduplication" ("expires_at") + ; `, } satisfies MigrationExecutor; diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 28c44ac2cfc..eec6019ea02 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -135,6 +135,12 @@ export interface document_preflight_scripts { updated_at: Date; } +export interface graphile_worker_deduplication { + dedupe_key: string; + expires_at: Date; + task_name: string; +} + export interface migration { date: Date; hash: string; @@ -441,6 +447,7 @@ export interface DBTables { document_collection_documents: document_collection_documents; document_collections: document_collections; document_preflight_scripts: document_preflight_scripts; + graphile_worker_deduplication: graphile_worker_deduplication; migration: migration; oidc_integrations: oidc_integrations; organization_access_tokens: organization_access_tokens; diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index fae40d86202..84cd5ea0d7b 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -35,6 +35,7 @@ const modules = await Promise.all([ import('./tasks/organization-invitation.js'), import('./tasks/organization-ownership-transfer.js'), import('./tasks/password-reset.js'), + import('./tasks/purge-expired-dedupe-keys.js'), import('./tasks/purge-expired-schema-checks.js'), import('./tasks/schema-change-notification.js'), import('./tasks/usage-rate-limit-exceeded.js'), @@ -44,6 +45,8 @@ const modules = await Promise.all([ const crontab = ` # Purge expired schema checks every Sunday at 10:00AM 0 10 * * 0 purgeExpiredSchemaChecks + # Every day at 3:00 AM + 0 3 * * * purgeExpiredDedupeKeys `; const pg = await createPool(env.postgres.connectionString); diff --git a/packages/services/workflows/src/tasks/purge-expired-dedupe-keys.ts b/packages/services/workflows/src/tasks/purge-expired-dedupe-keys.ts new file mode 100644 index 00000000000..b5fab222c6f --- /dev/null +++ b/packages/services/workflows/src/tasks/purge-expired-dedupe-keys.ts @@ -0,0 +1,25 @@ +import { sql } from 'slonik'; +import { z } from 'zod'; +import { defineTask, implementTask } from '../kit.js'; + +export const PurgeExpiredDedupeKeysTask = defineTask({ + name: 'purgeExpiredDedupeKeys', + schema: z.unknown(), +}); + +export const task = implementTask(PurgeExpiredDedupeKeysTask, async args => { + args.logger.debug('purging expired postgraphile task dedupe keys'); + const result = await args.context.pg.oneFirst(sql` + WITH "deleted" AS ( + DELETE FROM "graphile_worker_deduplication" + WHERE "expires_at" < NOW() + RETURNING 1 + ) + SELECT COUNT(*) FROM "deleted"; + `); + const amount = z.number().parse(result); + args.logger.debug( + { purgedCount: amount }, + 'finished purging expired postgraphile task dedupe keys', + ); +}); From a0180eceeea4410702672c0ed7d58a6c220507af Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 12 Dec 2025 18:23:05 +0100 Subject: [PATCH 37/37] :facepalm: --- packages/services/workflows/src/kit.ts | 7 +++++-- .../workflows/src/tasks/purge-expired-dedupe-keys.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/services/workflows/src/kit.ts b/packages/services/workflows/src/kit.ts index 76a06964463..49b1024999d 100644 --- a/packages/services/workflows/src/kit.ts +++ b/packages/services/workflows/src/kit.ts @@ -126,7 +126,9 @@ export class TaskScheduler { typeof opts.dedupe.key === 'string' ? opts.dedupe.key : opts.dedupe.key(payload); const expiresAt = new Date(new Date().getTime() + opts.dedupe.ttl).toISOString(); - const shouldSkip = await this.cache.getOrSet({ + let shouldSkip = false; + + await this.cache.getOrSet({ key: `${taskDefinition.name}:${dedupeKey}`, ttl: opts.dedupe.ttl, async factory() { @@ -144,7 +146,8 @@ export class TaskScheduler { [taskDefinition.name, dedupeKey, expiresAt], ); - return result.rows.length === 0; + shouldSkip = result.rows.length === 0; + return true; }); }, }); diff --git a/packages/services/workflows/src/tasks/purge-expired-dedupe-keys.ts b/packages/services/workflows/src/tasks/purge-expired-dedupe-keys.ts index b5fab222c6f..054ffefe215 100644 --- a/packages/services/workflows/src/tasks/purge-expired-dedupe-keys.ts +++ b/packages/services/workflows/src/tasks/purge-expired-dedupe-keys.ts @@ -13,7 +13,7 @@ export const task = implementTask(PurgeExpiredDedupeKeysTask, async args => { WITH "deleted" AS ( DELETE FROM "graphile_worker_deduplication" WHERE "expires_at" < NOW() - RETURNING 1 + RETURNING 1 ) SELECT COUNT(*) FROM "deleted"; `);