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
2 changes: 1 addition & 1 deletion apps/sim/app/api/billing/update-cost/replay.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ vi.mock('@sim/db', async () => {
})
state.client = client
const db = drizzle(client)
return { db, dbReplica: db }
return { db, dbReplica: db, dbFor: () => db }
})

/** Subscription and payment-provider fixtures; callback, ledger and replay code are real. */
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/billing/update-cost/worker.live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ vi.mock('@sim/db', async () => {
})
state.client = client
const db = drizzle(client)
return { db, dbReplica: db }
return { db, dbReplica: db, dbFor: () => db }
})

/** Subscription and payment-provider fixtures; callback, ledger and replay code are real. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ const fixture = vi.hoisted(() => ({
uploads: '',
}))

vi.mock('@sim/db', () => ({
db: new Proxy(
vi.mock('@sim/db', () => {
const db = new Proxy(
{},
{
get(_target, property) {
Expand All @@ -40,8 +40,9 @@ vi.mock('@sim/db', () => ({
return typeof value === 'function' ? value.bind(fixture.database) : value
},
}
),
}))
)
return { db, dbFor: () => db }
})
vi.mock('@/lib/uploads/core/setup.server', () => ({
get UPLOAD_DIR_SERVER() {
return fixture.uploads
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/api-key/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
import { createLogger, setRequestAuth } from '@sim/logger'
import { and, eq, isNull, lt, or } from 'drizzle-orm'
import { hashApiKey } from '@/lib/api-key/crypto'
import { getAuthPool } from '@/lib/auth/database-context'
import { createDetachedTouch } from '@/lib/core/utils/background'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceBillingSettings, type WorkspaceBillingSettings } from '@/lib/workspaces/utils'
Expand Down Expand Up @@ -55,14 +56,14 @@ export async function authenticateApiKeyFromHeader(
let workspaceSettings: WorkspaceBillingSettings | null = null

if (options.workspaceId) {
workspaceSettings = await getWorkspaceBillingSettings(options.workspaceId)
workspaceSettings = await getWorkspaceBillingSettings(options.workspaceId, getAuthPool())
if (!workspaceSettings) {
return { success: false, error: 'Workspace not found' }
}
}

const keyHash = hashApiKey(apiKeyHeader)
const rows: HashCandidate[] = await db
const rows: HashCandidate[] = await getAuthPool()
Comment thread
waleedlatif1 marked this conversation as resolved.
.select({
id: apiKeyTable.id,
userId: apiKeyTable.userId,
Expand Down
11 changes: 8 additions & 3 deletions apps/sim/lib/auth/database-context.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { db } from '@sim/db'
import { dbFor } from '@sim/db'
import type { DbOrTx } from '@/lib/db/types'

const authDatabase = new AsyncLocalStorage<DbOrTx>()

/** The Drizzle executor backing the current Better Auth transaction, or the root database. */
/** The auth pool's root client (see `DB_POOL_PROFILES.auth`). */
export function getAuthPool() {
return dbFor('auth')
}

/** The Drizzle executor backing the current Better Auth transaction, or the auth pool. */
export function getAuthDatabase(): DbOrTx {
return authDatabase.getStore() ?? db
return authDatabase.getStore() ?? getAuthPool()
}

/** Keeps database hooks on the adapter's connection so they can see uncommitted auth records. */
Expand Down
7 changes: 4 additions & 3 deletions apps/sim/lib/auth/oauth-provider-adapter-guard.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { db } from '@sim/db'
import type { db } from '@sim/db'
import { oauthConsent, oauthTokenFamily } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import type { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { APIError } from 'better-auth/api'
import { inArray } from 'drizzle-orm'
import { getAuthPool } from '@/lib/auth/database-context'
import { getOAuthIssuedResource } from '@/lib/auth/oauth-resource'
import { capabilityRefusal } from '@/lib/permission-groups/capabilities'
import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server'
Expand Down Expand Up @@ -37,7 +38,7 @@ async function deleteTrackedFamilies(
*/
export async function withOAuthProviderIssuanceCompensation<T extends Response>(
work: () => Promise<T>,
database: AuthDatabase = db
database: AuthDatabase = getAuthPool()
): Promise<T> {
const familyIds = new Set<string>()
try {
Expand Down Expand Up @@ -87,7 +88,7 @@ function requireConsentInsert(data: Record<string, unknown>): OAuthConsentInsert
*/
export function guardOAuthProviderWrites(
adapter: BetterAuthAdapter,
database: AuthDatabase = db
database: AuthDatabase = getAuthPool()
): BetterAuthAdapter {
return {
...adapter,
Expand Down
38 changes: 36 additions & 2 deletions apps/sim/lib/auth/security-policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { databaseMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getMemberOrganizationId,
getSecurityPolicyVersion,
Expand Down Expand Up @@ -146,3 +146,37 @@ describe('getSessionCookieCacheVersion', () => {
expect(limit).toHaveBeenCalledTimes(2)
})
})

describe('shared caches behind a sub-pool client', () => {
beforeEach(() => {
resetDbChainMock()
invalidateMembershipCache('pool-user')
invalidateSecurityPolicyVersionCache('pool-org')
})

afterEach(() => {
databaseMockFns.mockIsPrimaryPoolClient.mockReset()
})

/**
* Better Auth reads the cookie-cache version on every cached-session request through the auth
* pool, not `db`; caching only reads made through `db` would cost two queries per request.
*/
it('serves a repeat cookie-cache version from cache when read through the auth pool', async () => {
const authPool: typeof db = Object.create(db)
databaseMockFns.mockIsPrimaryPoolClient.mockImplementation(
(executor) => executor === db || executor === authPool
)
dbChainMockFns.limit
.mockResolvedValueOnce([{ organizationId: 'pool-org' }])
.mockResolvedValueOnce([{ version: 3 }])

await expect(getSessionCookieCacheVersion({ userId: 'pool-user' }, authPool)).resolves.toBe(
'pool-org:3'
)
await expect(getSessionCookieCacheVersion({ userId: 'pool-user' }, authPool)).resolves.toBe(
'pool-org:3'
)
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2)
})
})
10 changes: 5 additions & 5 deletions apps/sim/lib/auth/security-policy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { db } from '@sim/db'
import { db, isPrimaryPoolClient } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
Expand Down Expand Up @@ -50,7 +50,7 @@ export async function getSecurityPolicyVersion(
): Promise<number> {
if (!organizationId) return DEFAULT_VERSION

const cached = executor === db ? versionCache.get(organizationId) : undefined
const cached = isPrimaryPoolClient(executor) ? versionCache.get(organizationId) : undefined
if (cached !== undefined) return cached

try {
Expand All @@ -61,7 +61,7 @@ export async function getSecurityPolicyVersion(
.limit(1)

const version = row?.version ?? DEFAULT_VERSION
if (executor === db) versionCache.set(organizationId, version)
if (isPrimaryPoolClient(executor)) versionCache.set(organizationId, version)
return version
} catch (error) {
logger.error('Failed to resolve security policy version; using default', {
Expand Down Expand Up @@ -133,7 +133,7 @@ export async function getMemberOrganizationId(
if (!userId) return null

/** A transaction may have just created or moved the membership; shared cache entries cannot see it. */
const cached = executor === db ? membershipCache.get(userId) : undefined
const cached = isPrimaryPoolClient(executor) ? membershipCache.get(userId) : undefined
if (cached) return cached.organizationId

try {
Expand All @@ -144,7 +144,7 @@ export async function getMemberOrganizationId(
.limit(1)

const organizationId = row?.organizationId ?? null
if (executor === db) {
if (isPrimaryPoolClient(executor)) {
membershipCache.set(userId, { organizationId }, { ttl: membershipCacheTtlMs(organizationId) })
}
return organizationId
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/lib/auth/session-policy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { db } from '@sim/db'
import { db, isPrimaryPoolClient } from '@sim/db'
import type { SessionPolicySettings } from '@sim/db/schema'
import { organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
Expand Down Expand Up @@ -57,7 +57,7 @@ export async function getSessionPolicy(
if (!organizationId) return NO_POLICY

/** Uncommitted policy reads must neither consume nor populate the shared cache. */
const cached = executor === db ? policyCache.get(organizationId) : undefined
const cached = isPrimaryPoolClient(executor) ? policyCache.get(organizationId) : undefined
if (cached) return cached

try {
Expand All @@ -78,7 +78,7 @@ export async function getSessionPolicy(
idleTimeoutHours: settings.idleTimeoutHours ?? null,
}
: NO_POLICY
if (executor === db) policyCache.set(organizationId, policy)
if (isPrimaryPoolClient(executor)) policyCache.set(organizationId, policy)
return policy
} catch (error) {
logger.error('Failed to resolve session policy; applying no policy', {
Expand Down
5 changes: 2 additions & 3 deletions apps/sim/lib/auth/sim-auth-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import type { BetterAuthOptions } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { APIError } from 'better-auth/api'
import { and, eq } from 'drizzle-orm'
import { runWithAuthDatabase } from '@/lib/auth/database-context'
import { getAuthPool, runWithAuthDatabase } from '@/lib/auth/database-context'
import {
type AuthDatabase,
guardOAuthProviderWrites,
Expand Down Expand Up @@ -66,7 +65,7 @@ function createTransactionAdapter(
*/
export function createSimAuthAdapter(
options: BetterAuthOptions,
database: AuthDatabase = db
database: AuthDatabase = getAuthPool()
): BetterAuthAdapter {
const guarded = createGuardedAdapter(options, database)
const transaction: BetterAuthAdapter['transaction'] = (callback) =>
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/lib/auth/sso-policy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { db } from '@sim/db'
import { db, isPrimaryPoolClient } from '@sim/db'
import { organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { APIError } from 'better-auth/api'
Expand Down Expand Up @@ -43,7 +43,7 @@ export async function isSsoRequiredForOrganization(
if (!organizationId) return false

/** Uncommitted reads must neither consume nor populate the shared cache. */
const cached = executor === db ? requirementCache.get(organizationId) : undefined
const cached = isPrimaryPoolClient(executor) ? requirementCache.get(organizationId) : undefined
if (cached !== undefined) return cached

const [row] = await executor
Expand All @@ -64,7 +64,7 @@ export async function isSsoRequiredForOrganization(
onError: 'throw',
})) &&
(await hasSignInCapableSsoProvider(organizationId, executor))
if (executor === db) requirementCache.set(organizationId, required)
if (isPrimaryPoolClient(executor)) requirementCache.set(organizationId, required)
return required
}

Expand Down
10 changes: 9 additions & 1 deletion apps/sim/lib/billing/core/reporting-usage-cache.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ const { transaction, summedPeriods } = vi.hoisted(() => ({
const databaseUrl = readTestDatabaseUrl()
const redisUrl = readTestRedisUrl()

vi.mock('@sim/db', () => ({ db: { transaction }, dbReplica: {} }))
vi.mock('@sim/db', () => {
const db = { transaction }
return {
db,
dbFor: () => ({ transaction }),
dbReplica: {},
isPrimaryPoolClient: (executor: object) => executor === db,
}
})
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
vi.mock('@/lib/billing/core/usage-log', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/billing/core/usage-log')>()
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/billing/core/reporting-usage-cache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { db } from '@sim/db'
import { db, isPrimaryPoolClient } from '@sim/db'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
Expand Down Expand Up @@ -417,7 +417,7 @@ export function readSoftGateUsageCost(
period: UsageQueryPeriod,
executor: DbClient = db
): Promise<number> {
if (isReportingPeriod(period) && executor === db) {
if (isReportingPeriod(period) && isPrimaryPoolClient(executor)) {
return readCachedReportingUsageCost(entity, period)
}
return getBillingPeriodUsageCost(entity, period, undefined, executor)
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/billing/core/usage-log.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
const { transaction } = vi.hoisted(() => ({ transaction: vi.fn() }))
const databaseUrl = readTestDatabaseUrl()

vi.mock('@sim/db', () => ({ db: { transaction }, dbReplica: {} }))
vi.mock('@sim/db', () => ({ db: { transaction }, dbFor: () => ({ transaction }), dbReplica: {} }))
vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: vi.fn() }))
vi.mock('@/lib/billing/subscriptions/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/billing/subscriptions/utils')>()),
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/lib/billing/core/usage-log.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { db, dbReplica } from '@sim/db'
import { db, dbFor, dbReplica } from '@sim/db'
import { subscription as subscriptionTable, usageLog, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toNumberOrNull } from '@sim/utils/coerce'
Expand Down Expand Up @@ -475,7 +475,7 @@ export async function recordUsage(params: RecordUsageParams): Promise<void> {

const context = await resolveBillingContext(userId, billingEntity, billingPeriod)

const insertedRows = await (tx ?? db)
const insertedRows = await (tx ?? dbFor('billing'))
.insert(usageLog)
.values(
validEntries.map((entry, index) => {
Expand Down Expand Up @@ -842,7 +842,7 @@ export async function recordCumulativeUsage(
}

try {
const result = await db.transaction(async (tx) => {
const result = await dbFor('billing').transaction(async (tx) => {
enterStage('configure')
await tx.execute(sql`
select
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/lib/billing/threshold-billing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { db, dbFor } from '@sim/db'
import { member, organization, subscription, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
Expand Down Expand Up @@ -343,7 +343,7 @@ export async function checkAndBillOverageThreshold(
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
const totalOverageCents = Math.round(currentOverage * 100)

const billedResult = await db.transaction(
const billedResult = await dbFor('billing').transaction(
async (tx): Promise<InternalThresholdSettlementResult> => {
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))

Expand Down Expand Up @@ -678,7 +678,7 @@ async function checkAndBillOrganizationOverageThreshold(
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
const totalOverageCents = Math.round(currentOverage * 100)

const orgBilledResult = await db.transaction(
const orgBilledResult = await dbFor('billing').transaction(
async (
tx
): Promise<
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export const env = createEnv({
DATABASE_URL_REALTIME: z.string().url().optional(), // Per-role primary URL override (realtime)
DATABASE_URL_CLEANUP: z.string().url().optional(), // Sub-process pool URL override (cleanup jobs, via dbFor)
DATABASE_URL_EXEC: z.string().url().optional(), // Sub-process pool URL override (inline execution writes, via dbFor)
DATABASE_URL_BILLING: z.string().url().optional(), // Sub-process pool URL override (usage-ledger writes, via dbFor)
DATABASE_URL_AUTH: z.string().url().optional(), // Sub-process pool URL override (session and API-key lookups, via dbFor)
DATABASE_REPLICA_URL_WEB: z.string().url().optional(), // Per-role replica URL override; falls back to DATABASE_REPLICA_URL
DATABASE_REPLICA_URL_TRIGGER: z.string().url().optional(), // Per-role replica URL override (trigger)
DATABASE_REPLICA_URL_REALTIME: z.string().url().optional(), // Per-role replica URL override (realtime)
Expand Down
Loading
Loading