Skip to content

Commit d90b521

Browse files
authored
Merge branch 'dev' into inline-product-cancelling
2 parents 63c1391 + 145bcb7 commit d90b521

207 files changed

Lines changed: 24625 additions & 2399 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"typescript.tsdk": "node_modules/typescript/lib",
88
"editor.tabSize": 2,
99
"cSpell.words": [
10+
"glassmorphic",
1011
"sparkline",
1112
"Clickhouse",
1213
"pushable",

AGENTS.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ You should ALWAYS add new E2E tests when you change the API or SDK interface. Ge
2323
- **Run some tests**: `pnpm test run <file-filters>`
2424

2525
### Database Commands
26-
- **Generate migration**: `pnpm db:migration-gen`
26+
- **Generate migration**: `pnpm db:migration-gen` — NOTE: don't forget to create tests for the migrations!
2727
- **Reset database** (rarely used): `pnpm db:reset`
2828
- **Seed database** (rarely used): `pnpm db:seed`
2929
- **Initialize database** (rarely used): `pnpm db:init`
@@ -100,8 +100,12 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
100100
- NEVER implement a hacky solution without EXPLICIT approval from the user. Always go the extra mile to make sure the solution is clean, maintainable, and robust.
101101
- Fail early, fail loud. Fail fast with an error instead of silently continuing.
102102
- Do NOT use `as`/`any`/type casts or anything else like that to bypass the type system unless you specifically asked the user about it. Most of the time a place where you would use type casts is not one where you actually need them. Avoid wherever possible.
103-
- When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples.
103+
- When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples. One common pattern is to add a temporary extra boolean column
104+
- Each migration file runs in its own transaction with a relatively short timeout. Split long-running operations into separate migration files to avoid timeouts. For example, when adding CHECK constraints, use `NOT VALID` in one migration, then `VALIDATE CONSTRAINT` in a separate migration file.
105+
- Note that each database migration file is executed in a single transaction. Even with the run-outside-transaction sentinel, the transaction will still continue during the entire migration file. If you want to split things up into multiple transactions, put it into their own migration files.
106+
- When writing database migration files, ALWAYS ALWAYS add tests for all the potential edge cases! See the folder structure of the other migrations to see how that works.
104107
- **When building frontend code, always carefully deal with loading and error states.** Be very explicit with these; some components make this easy, eg. the button onClick already takes an async callback for loading state, but make sure this is done everywhere, and make sure errors are NEVER just silently swallowed.
108+
- Any design components you add or modify in the dashboard, update the Playground page accordingly to showcase the changes.
105109
- Unless very clearly equivalent from types, prefer explicit null/undefinedness checks over boolean checks, eg. `foo == null` instead of `!foo`.
106110

107111
### Code-related

CLAUDE.md

-820 Bytes
Loading

apps/backend/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ STACK_S3_REGION=
6767
STACK_S3_ACCESS_KEY_ID=
6868
STACK_S3_SECRET_ACCESS_KEY=
6969
STACK_S3_BUCKET=
70+
STACK_S3_PRIVATE_BUCKET=
7071

7172
# AWS configuration
7273
STACK_AWS_REGION=

apps/backend/.env.development

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ STACK_OPENAI_API_KEY=mock_openai_api_key
5858
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
5959
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
6060

61+
STACK_OPENROUTER_API_KEY=mock-openrouter-api-key
62+
6163
# Email monitor configuration for tests
6264
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification
6365
STACK_EMAIL_MONITOR_PROJECT_ID=internal
@@ -74,6 +76,7 @@ STACK_S3_REGION=us-east-1
7476
STACK_S3_ACCESS_KEY_ID=s3mockroot
7577
STACK_S3_SECRET_ACCESS_KEY=s3mockroot
7678
STACK_S3_BUCKET=stack-storage
79+
STACK_S3_PRIVATE_BUCKET=stack-storage-private
7780

7881
# AWS region defaults to LocalStack
7982
STACK_AWS_REGION=us-east-1

apps/backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stackframe/stack-backend",
3-
"version": "2.8.67",
3+
"version": "2.8.68",
44
"repository": "https://github.com/stack-auth/stack-auth",
55
"private": true,
66
"type": "module",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { randomUUID } from 'crypto';
2+
import type { Sql } from 'postgres';
3+
import { expect } from 'vitest';
4+
5+
export const preMigration = async (sql: Sql) => {
6+
const projectId = `test-${randomUUID()}`;
7+
const tenancyId = randomUUID();
8+
const userId1 = randomUUID();
9+
const userId2 = randomUUID();
10+
11+
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`;
12+
await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`;
13+
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") VALUES (${userId1}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())`;
14+
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") VALUES (${userId2}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())`;
15+
16+
return { projectId };
17+
};
18+
19+
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
20+
const rows = await sql`
21+
SELECT "restrictedByAdmin", "restrictedByAdminReason"
22+
FROM "ProjectUser"
23+
WHERE "mirroredProjectId" = ${ctx.projectId}
24+
`;
25+
26+
expect(rows).toHaveLength(2);
27+
for (const row of rows) {
28+
expect(row.restrictedByAdmin).toBe(false);
29+
expect(row.restrictedByAdminReason).toBeNull();
30+
}
31+
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { randomUUID } from 'crypto';
2+
import type { Sql } from 'postgres';
3+
import { expect } from 'vitest';
4+
5+
export const preMigration = async (sql: Sql) => {
6+
const projectId = `test-${randomUUID()}`;
7+
const tenancyId = randomUUID();
8+
const unrestricted = randomUUID();
9+
const restrictedWithReason = randomUUID();
10+
const restrictedNoReason = randomUUID();
11+
12+
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Test', '', false)`;
13+
await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`;
14+
15+
// Unrestricted user (valid: false + null reason)
16+
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt") VALUES (${unrestricted}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())`;
17+
18+
// Restricted with reason
19+
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt", "restrictedByAdmin", "restrictedByAdminReason") VALUES (${restrictedWithReason}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW(), true, 'spam')`;
20+
21+
// Restricted without reason
22+
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt", "restrictedByAdmin") VALUES (${restrictedNoReason}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW(), true)`;
23+
24+
return { projectId, tenancyId, unrestricted, restrictedWithReason, restrictedNoReason };
25+
};
26+
27+
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
28+
// Existing valid rows should still be there
29+
const rows = await sql`
30+
SELECT "projectUserId", "restrictedByAdmin", "restrictedByAdminReason", "restrictedByAdminPrivateDetails"
31+
FROM "ProjectUser"
32+
WHERE "mirroredProjectId" = ${ctx.projectId}
33+
ORDER BY "projectUserId"
34+
`;
35+
expect(rows).toHaveLength(3);
36+
37+
for (const row of rows) {
38+
expect(row.restrictedByAdminPrivateDetails).toBeNull();
39+
}
40+
41+
// Restricted user can have private details set
42+
await sql`UPDATE "ProjectUser" SET "restrictedByAdminPrivateDetails" = 'internal notes' WHERE "projectUserId" = ${ctx.restrictedWithReason}::uuid`;
43+
44+
// INVALID: unrestricted user with a reason should fail
45+
await expect(sql`
46+
UPDATE "ProjectUser" SET "restrictedByAdminReason" = 'should fail' WHERE "projectUserId" = ${ctx.unrestricted}::uuid
47+
`).rejects.toThrow(/ProjectUser_restricted_by_admin_consistency/);
48+
49+
// INVALID: unrestricted user with private details should fail
50+
await expect(sql`
51+
UPDATE "ProjectUser" SET "restrictedByAdminPrivateDetails" = 'should fail' WHERE "projectUserId" = ${ctx.unrestricted}::uuid
52+
`).rejects.toThrow(/ProjectUser_restricted_by_admin_consistency/);
53+
54+
// VALID: new restricted user with all fields
55+
const newUser = randomUUID();
56+
await sql`INSERT INTO "ProjectUser" ("projectUserId", "tenancyId", "mirroredProjectId", "mirroredBranchId", "createdAt", "updatedAt", "lastActiveAt", "restrictedByAdmin", "restrictedByAdminReason", "restrictedByAdminPrivateDetails") VALUES (${newUser}::uuid, ${ctx.tenancyId}::uuid, ${ctx.projectId}, 'main', NOW(), NOW(), NOW(), true, 'test', 'details')`;
57+
58+
// VALID: un-restricting clears reason and details
59+
await sql`UPDATE "ProjectUser" SET "restrictedByAdmin" = false, "restrictedByAdminReason" = NULL, "restrictedByAdminPrivateDetails" = NULL WHERE "projectUserId" = ${newUser}::uuid`;
60+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- Add deferred retry fields for email sending
2+
-- These fields allow the email queue to schedule retries for later iterations
3+
-- instead of blocking the current iteration with inline retries.
4+
5+
ALTER TABLE "EmailOutbox"
6+
ADD COLUMN "sendRetries" INTEGER NOT NULL DEFAULT 0,
7+
ADD COLUMN "nextSendRetryAt" TIMESTAMP(3),
8+
ADD COLUMN "sendAttemptErrors" JSONB;
9+
10+
-- Constraint: nextSendRetryAt can only be set after at least one failed attempt
11+
-- (if sendRetries is 0, no attempt has failed, so there's nothing to retry)
12+
-- Use NOT VALID to avoid holding ACCESS EXCLUSIVE lock during full-table validation.
13+
-- Validation happens in a separate migration to avoid transaction timeout.
14+
ALTER TABLE "EmailOutbox"
15+
ADD CONSTRAINT "EmailOutbox_nextSendRetryAt_requires_failure"
16+
CHECK ("nextSendRetryAt" IS NULL OR "sendRetries" > 0) NOT VALID;
17+
18+
-- Constraint: sendAttemptErrors can only be set after at least one failed attempt
19+
ALTER TABLE "EmailOutbox"
20+
ADD CONSTRAINT "EmailOutbox_sendAttemptErrors_requires_failure"
21+
CHECK ("sendAttemptErrors" IS NULL OR "sendRetries" > 0) NOT VALID;
22+
23+
-- Constraint: nextSendRetryAt must be null when email has finished sending
24+
-- (if finishedSendingAt is set, there's nothing more to retry)
25+
ALTER TABLE "EmailOutbox"
26+
ADD CONSTRAINT "EmailOutbox_no_retry_after_finished"
27+
CHECK ("finishedSendingAt" IS NULL OR "nextSendRetryAt" IS NULL) NOT VALID;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Validate the deferred retry constraints added in the previous migration.
2+
-- This runs in a separate transaction to avoid timeout, and only takes
3+
-- SHARE UPDATE EXCLUSIVE lock (allows concurrent reads/writes).
4+
5+
ALTER TABLE "EmailOutbox" VALIDATE CONSTRAINT "EmailOutbox_nextSendRetryAt_requires_failure";
6+
ALTER TABLE "EmailOutbox" VALIDATE CONSTRAINT "EmailOutbox_sendAttemptErrors_requires_failure";
7+
ALTER TABLE "EmailOutbox" VALIDATE CONSTRAINT "EmailOutbox_no_retry_after_finished";

0 commit comments

Comments
 (0)