Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
-- CreateTable
CREATE TABLE "WorkflowTriggerToken" (
"tenancyId" UUID NOT NULL,
"id" UUID NOT NULL,
"tokenHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "WorkflowTriggerToken_pkey" PRIMARY KEY ("tenancyId","id")
);

-- CreateTable
CREATE TABLE "WorkflowTrigger" (
"tenancyId" UUID NOT NULL,
"id" UUID NOT NULL,
"executionId" UUID NOT NULL,
"triggerData" JSONB NOT NULL,
"scheduledAt" TIMESTAMP(3),
"output" JSONB,
"error" JSONB,
"compiledWorkflowId" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "WorkflowTrigger_pkey" PRIMARY KEY ("tenancyId","id")
);

-- CreateTable
CREATE TABLE "WorkflowExecution" (
"tenancyId" UUID NOT NULL,
"id" UUID NOT NULL,
"workflowId" TEXT NOT NULL,
"triggerIds" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
Comment on lines +30 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove triggerIds array to avoid drift; rely on FK from WorkflowTrigger

WorkflowTrigger already has executionId -> WorkflowExecution. Keeping triggerIds TEXT[] duplicates the relationship without FK guarantees and can desynchronize, causing data integrity issues.

Apply (either edit this migration before apply, or follow-up migration to drop the column):

 CREATE TABLE "WorkflowExecution" (
   "tenancyId" UUID NOT NULL,
   "id" UUID NOT NULL,
   "workflowId" TEXT NOT NULL,
-  "triggerIds" TEXT[],
   "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
   "updatedAt" TIMESTAMP(3) NOT NULL,
 
   CONSTRAINT "WorkflowExecution_pkey" PRIMARY KEY ("tenancyId","id")
 );

Optional: add an index for common lookups:

CREATE INDEX "WorkflowExecution_tenancyId_workflowId_idx"
  ON "WorkflowExecution"("tenancyId","workflowId");
🤖 Prompt for AI Agents
In apps/backend/prisma/migrations/20250903080405_workflows/migration.sql around
lines 30-36, remove the redundant "triggerIds" TEXT[] column from the
WorkflowExecution CREATE TABLE statement (or if the migration has already been
applied, create a follow-up migration that drops the triggerIds column) so the
relationship is enforced only via the WorkflowTrigger.executionId FK;
additionally consider adding the suggested composite index on (tenancyId,
workflowId) for common lookups by creating an index named
WorkflowExecution_tenancyId_workflowId_idx.


CONSTRAINT "WorkflowExecution_pkey" PRIMARY KEY ("tenancyId","id")
);

-- CreateTable
CREATE TABLE "CurrentlyCompilingWorkflow" (
"tenancyId" UUID NOT NULL,
"workflowId" TEXT NOT NULL,
"compilationVersion" INTEGER NOT NULL,
"sourceHash" TEXT NOT NULL,
"startedCompilingAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "CurrentlyCompilingWorkflow_pkey" PRIMARY KEY ("tenancyId","workflowId","compilationVersion","sourceHash")
);

-- CreateTable
CREATE TABLE "CompiledWorkflow" (
"tenancyId" UUID NOT NULL,
"id" UUID NOT NULL,
"workflowId" TEXT NOT NULL,
"compilationVersion" INTEGER NOT NULL,
"sourceHash" TEXT NOT NULL,
"compiledCode" TEXT,
"compileError" TEXT,
"compiledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"registeredTriggers" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "CompiledWorkflow_pkey" PRIMARY KEY ("tenancyId","id")
);

-- CreateIndex
CREATE UNIQUE INDEX "WorkflowTriggerToken_tenancyId_tokenHash_key" ON "WorkflowTriggerToken"("tenancyId", "tokenHash");

-- CreateIndex
CREATE UNIQUE INDEX "CompiledWorkflow_tenancyId_workflowId_compilationVersion_so_key" ON "CompiledWorkflow"("tenancyId", "workflowId", "compilationVersion", "sourceHash");

-- AddForeignKey
ALTER TABLE "WorkflowTrigger" ADD CONSTRAINT "WorkflowTrigger_tenancyId_compiledWorkflowId_fkey" FOREIGN KEY ("tenancyId", "compiledWorkflowId") REFERENCES "CompiledWorkflow"("tenancyId", "id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "WorkflowTrigger" ADD CONSTRAINT "WorkflowTrigger_tenancyId_executionId_fkey" FOREIGN KEY ("tenancyId", "executionId") REFERENCES "WorkflowExecution"("tenancyId", "id") ON DELETE RESTRICT ON UPDATE CASCADE;
110 changes: 102 additions & 8 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ model Project {
fullLogoUrl String?

projectConfigOverride Json?
stripeAccountId String?
stripeAccountId String?

apiKeySets ApiKeySet[]
projectUsers ProjectUser[]
Expand Down Expand Up @@ -773,15 +773,109 @@ model ItemQuantityChange {
}

model DataVaultEntry {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
storeId String
hashedKey String
encrypted Json // Contains { edkBase64, ciphertextBase64 } from encryptWithKms()
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
storeId String
hashedKey String
encrypted Json // Contains { edkBase64, ciphertextBase64 } from encryptWithKms()
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@id([tenancyId, id])
@@unique([tenancyId, storeId, hashedKey])
@@index([tenancyId, storeId])
}

model WorkflowTriggerToken {
tenancyId String @db.Uuid
id String @default(uuid()) @db.Uuid

tokenHash String

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

expiresAt DateTime

@@id([tenancyId, id])
@@unique([tenancyId, tokenHash])
}

model WorkflowTrigger {
tenancyId String @db.Uuid
id String @default(uuid()) @db.Uuid
executionId String @db.Uuid

triggerData Json

// the following fields determine the state of the trigger:
// - scheduledAt && !compiledWorkflowId && !output && !error: the trigger is scheduled to be executed
// - !scheduledAt && compiledWorkflowId && !output && !error: the trigger is currently executing
// - !scheduledAt && compiledWorkflowId && output && !error: the trigger has successfully completed execution
// - !scheduledAt && compiledWorkflowId && !output && error: the trigger has failed execution
// All other combinations are invalid.
scheduledAt DateTime?
output Json?
error Json?
compiledWorkflowId String? @db.Uuid
compiledWorkflow CompiledWorkflow? @relation(fields: [tenancyId, compiledWorkflowId], references: [tenancyId, id])

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

execution WorkflowExecution @relation(fields: [tenancyId, executionId], references: [tenancyId, id])

@@id([tenancyId, id])
}

model WorkflowExecution {
tenancyId String @db.Uuid
id String @default(uuid()) @db.Uuid

workflowId String

triggerIds String[]
triggers WorkflowTrigger[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@id([tenancyId, id])
}

model CurrentlyCompilingWorkflow {
tenancyId String @db.Uuid
workflowId String
compilationVersion Int
sourceHash String

startedCompilingAt DateTime @default(now())

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@id([tenancyId, workflowId, compilationVersion, sourceHash])
}

model CompiledWorkflow {
tenancyId String @db.Uuid
id String @default(uuid()) @db.Uuid
workflowId String // note: The workflow with this ID may have been edited or deleted in the meantime, so there may be multiple CompiledWorkflows with the same workflowId
compilationVersion Int
sourceHash String

// exactly one of [compiledCode, compileError] must be set
compiledCode String?
compileError String?

compiledAt DateTime @default(now())
registeredTriggers String[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

workflowTriggers WorkflowTrigger[]

@@id([tenancyId, id])
@@unique([tenancyId, workflowId, compilationVersion, sourceHash])
}
18 changes: 17 additions & 1 deletion apps/backend/src/app/api/latest/users/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-chec
import { Tenancy, getSoleTenancyFromProjectBranch, getTenancy } from "@/lib/tenancies";
import { PrismaTransaction } from "@/lib/types";
import { sendTeamMembershipDeletedWebhook, sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks";
import { triggerWorkflows } from "@/lib/workflows";
import { RawQuery, getPrismaClientForSourceOfTruth, getPrismaClientForTenancy, getPrismaSchemaForSourceOfTruth, getPrismaSchemaForTenancy, globalPrismaClient, rawQuery, retryTransaction, sqlQuoteIdent } from "@/prisma-client";
import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { uploadAndGetUrl } from "@/s3";
Expand Down Expand Up @@ -648,6 +649,14 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC

await createPersonalTeamIfEnabled(prisma, auth.tenancy, result);

// if the user is not an anonymous user, trigger onSignUp workflows
if (!result.is_anonymous) {
await triggerWorkflows(auth.tenancy, {
type: "sign-up",
userId: result.id,
});
}
Comment on lines +652 to +658
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Don’t block user creation on workflow execution; make it fire-and-forget.

If any workflow throws, the endpoint 500s even though the user is already persisted. This couples signup UX to arbitrary workflow code and increases latency. Mirror the webhook pattern and dispatch asynchronously.

Apply this diff:

-    if (!result.is_anonymous) {
-      await triggerWorkflows(auth.tenancy, {
-        type: "sign-up",
-        userId: result.id,
-      });
-    }
+    if (!result.is_anonymous) {
+      // Run in the background to avoid failing user creation on workflow errors
+      runAsynchronouslyAndWaitUntil(
+        triggerWorkflows(auth.tenancy, {
+          type: "sign-up",
+          userId: result.id,
+        })
+      );
+    }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/backend/src/app/api/latest/users/crud.tsx around lines 652 to 658, the
code awaits triggerWorkflows during signup which can cause the endpoint to 500
or increase latency when workflows fail; change this to fire-and-forget by
invoking triggerWorkflows without awaiting and attach a .catch that logs any
errors (or schedule via void Promise.resolve().then(() =>
triggerWorkflows(...)).catch(...)) so failures don’t propagate to the response
path and signup remains non-blocking.


runAsynchronouslyAndWaitUntil(sendUserCreatedWebhook({
projectId: auth.project.id,
data: result,
Expand Down Expand Up @@ -948,8 +957,15 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
}
}

// if we went from anonymous to non-anonymous, rename the personal team
// if we went from anonymous to non-anonymous:
if (oldUser.isAnonymous && data.is_anonymous === false) {
// trigger onSignUp workflows
await triggerWorkflows(auth.tenancy, {
type: "sign-up",
userId: params.user_id,
});

// rename the personal team
Comment on lines +960 to +968
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Critical: Triggering workflows inside retryTransaction causes duplicate side effects and long transactions.

retryTransaction(...) may re-run the callback on serialization conflicts. Calling await triggerWorkflows(...) inside it can double-trigger workflows and holds the DB txn open for external work. Move the trigger outside the transaction and flag within.

Apply this diff:

   const prisma = await getPrismaClientForTenancy(auth.tenancy);
-  const { user } = await retryTransaction(prisma, async (tx) => {
+  let shouldTriggerSignUp = false;
+  const { user } = await retryTransaction(prisma, async (tx) => {
@@
-      if (oldUser.isAnonymous && data.is_anonymous === false) {
-        // trigger onSignUp workflows
-        await triggerWorkflows(auth.tenancy, {
-          type: "sign-up",
-          userId: params.user_id,
-        });
-
-        // rename the personal team
+      if (oldUser.isAnonymous && data.is_anonymous === false) {
+        // mark for post-commit workflow trigger
+        shouldTriggerSignUp = true;
+        // rename the personal team
         await tx.team.updateMany({
           where: {
             tenancyId: auth.tenancy.id,
@@
       return {
         user,
       };
     });
 
+    if (shouldTriggerSignUp) {
+      // Dispatch asynchronously to avoid blocking the update path and duplicate side effects on retries
+      runAsynchronouslyAndWaitUntil(
+        triggerWorkflows(auth.tenancy, {
+          type: "sign-up",
+          userId: params.user_id,
+        })
+      );
+    }

Run to confirm the trigger type is valid and to surface other call sites:


🏁 Script executed:

#!/bin/bash
# Verify 'sign-up' is an allowed WorkflowTrigger type and find all usages
rg -nP --type=ts '\bWorkflowTrigger\b' -C2
rg -nP --type=ts "(\"|')sign-up(\"|')" -C2

Length of output: 8641


🏁 Script executed:

#!/bin/bash
# Show all retryTransaction usages in the users CRUD file with context
rg -nP --type=ts 'retryTransaction\(' -C4 apps/backend/src/app/api/latest/users/crud.tsx

Length of output: 1414


🏁 Script executed:

#!/bin/bash
# Locate definition of retryTransaction
rg -nP --type=ts "async function retryTransaction" -C3 .
rg -nP --type=ts "retryTransaction\\s*=" -C3 .

Length of output: 732


🏁 Script executed:

#!/bin/bash
sed -n '115,180p' apps/backend/src/prisma-client.tsx

Length of output: 3778


🏁 Script executed:

#!/bin/bash
# Extract create path around retryTransaction and subsequent triggerWorkflows
sed -n '630,700p' apps/backend/src/app/api/latest/users/crud.tsx

Length of output: 2300


Move triggerWorkflows call out of retryTransaction in onUpdate
The onUpdate handler invokes await triggerWorkflows(...) inside retryTransaction, which can double-trigger workflows on retries and blocks the transaction on external work. Mirror the onCreate pattern: set a flag inside the transaction, then after retryTransaction completes call

runAsynchronouslyAndWaitUntil(
  triggerWorkflows(auth.tenancy, { type: "sign-up", userId: params.user_id })
);
🤖 Prompt for AI Agents
In apps/backend/src/app/api/latest/users/crud.tsx around lines 960 to 968, the
code currently awaits triggerWorkflows(...) inside retryTransaction which can
cause double-triggering on retries and blocks the transaction; change this by
setting a local boolean flag inside the transaction when oldUser.isAnonymous &&
data.is_anonymous === false (do NOT call triggerWorkflows there), then after
retryTransaction completes check that flag and call
runAsynchronouslyAndWaitUntil(triggerWorkflows(auth.tenancy, { type: "sign-up",
userId: params.user_id })); remove the await triggerWorkflows from inside the
transaction so external work runs only once after the transaction succeeds.

await tx.team.updateMany({
where: {
tenancyId: auth.tenancy.id,
Expand Down
10 changes: 4 additions & 6 deletions apps/backend/src/lib/email-rendering.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Freestyle } from '@/lib/freestyle';
import { emptyEmailTheme } from '@stackframe/stack-shared/dist/helpers/emails';
import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors';
import { bundleJavaScript } from '@stackframe/stack-shared/dist/utils/esbuild';
import { get, has } from '@stackframe/stack-shared/dist/utils/objects';
Expand Down Expand Up @@ -53,7 +52,6 @@ export async function renderEmailWithTemplate(
previewMode?: boolean,
},
): Promise<Result<{ html: string, text: string, subject?: string, notificationCategory?: string }, string>> {
const apiKey = getEnvVariable("STACK_FREESTYLE_API_KEY");
const variables = options.variables ?? {};
const previewMode = options.previewMode ?? false;
const user = (previewMode && !options.user) ? { displayName: "John Doe" } : options.user;
Expand Down Expand Up @@ -114,17 +112,17 @@ export async function renderEmailWithTemplate(
return Result.error(result.error);
}

const freestyle = new Freestyle({ apiKey });
const freestyle = new Freestyle();
const nodeModules = {
"react": "19.1.1",
"@react-email/components": "0.1.1",
"arktype": "2.1.20",
};
const output = await freestyle.executeScript(result.data, { nodeModules });
if ("error" in output) {
return Result.error(output.error as string);
if (output.status === "error") {
return Result.error(`${output.error}`);
}
return Result.ok(output.result as { html: string, text: string, subject: string, notificationCategory: string });
return Result.ok(output.data.result as { html: string, text: string, subject: string, notificationCategory: string });
}


Expand Down
29 changes: 20 additions & 9 deletions apps/backend/src/lib/freestyle.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import { traceSpan } from '@/utils/telemetry';
import { getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
import { StackAssertionError, captureError, errorToNiceString } from '@stackframe/stack-shared/dist/utils/errors';
import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors';
import { parseJson } from '@stackframe/stack-shared/dist/utils/json';
import { Result } from '@stackframe/stack-shared/dist/utils/results';
import { FreestyleSandboxes } from 'freestyle-sandboxes';

export class Freestyle {
private freestyle: FreestyleSandboxes;

constructor(options: { apiKey: string }) {
constructor(options: { apiKey?: string } = {}) {
const apiKey = options.apiKey || getEnvVariable("STACK_FREESTYLE_API_KEY");
let baseUrl = undefined;
if (["development", "test"].includes(getNodeEnvironment()) && options.apiKey === "mock_stack_freestyle_key") {
if (apiKey === "mock_stack_freestyle_key") {
if (!["development", "test"].includes(getNodeEnvironment())) {
throw new StackAssertionError("Mock Freestyle key used in production; please set the STACK_FREESTYLE_API_KEY environment variable.");
}
baseUrl = "http://localhost:8122";
}
this.freestyle = new FreestyleSandboxes({
apiKey: options.apiKey,
apiKey,
baseUrl,
});
}
Expand All @@ -27,10 +33,15 @@ export class Freestyle {
}
}, async () => {
try {
return await this.freestyle.executeScript(script, options);
} catch (error) {
captureError("freestyle.executeScript", error);
throw new StackAssertionError("Error executing script with Freestyle! " + errorToNiceString(error), { cause: error });
const res = await this.freestyle.executeScript(script, options);
return Result.ok(res);
} catch (e: unknown) {
// for whatever reason, Freestyle's errors are sometimes returned in JSON.parse(e.error.error).error (lol)
const wrap1 = e && typeof e === "object" && "error" in e ? e.error : e;
const wrap2 = wrap1 && typeof wrap1 === "object" && "error" in wrap1 ? wrap1.error : wrap1;
const wrap3 = wrap2 && typeof wrap2 === "string" ? Result.or(parseJson(wrap2), wrap2) : wrap2;
const wrap4 = wrap3 && typeof wrap3 === "object" && "error" in wrap3 ? wrap3.error : wrap3;
return Result.error(`${wrap4}`);
}
});
}
Expand Down
Loading
Loading