Skip to content

Commit 93e040f

Browse files
committed
perf(server): memoize the request-scoped workspace and entitlement reads
The workspace row was read ~3x per workspace route and ~5x on settings, and the same Max-tier entitlement was resolved twice on one render. Memoization is deliberately partial. getWorkspaceWithOwner accepts a transaction and forUpdate, and live callers use both, so only the plain no-options read routes through the memo; a row read inside one caller's transaction or under a lock it alone holds can never be served to a later caller. includeArchived is part of the key so the two variants cannot alias. Three substitutions were considered and rejected as behavior changes, not optimizations: hostContext.ownerBilling resolves subscriptions differently from hasWorkspaceTierAccess and exposes no Max tier, so it cannot answer the Inbox/Sandbox gates; isOrganizationOnEnterprisePlan carries self-host short-circuits ownerBilling has no equivalent for; and widening WorkspaceHostContext to carry the full row would push owner and org ids onto the wire for every viewer to save a server-side read, since that type is a response contract rather than an internal struct.
1 parent 6df827b commit 93e040f

3 files changed

Lines changed: 89 additions & 31 deletions

File tree

‎apps/sim/lib/billing/core/subscription.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { cache } from 'react'
12
import { db } from '@sim/db'
23
import { member, organization, subscription, user } from '@sim/db/schema'
34
import { createLogger } from '@sim/logger'
@@ -429,11 +430,7 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boole
429430
}
430431
}
431432

432-
/**
433-
* Check if an organization has an enterprise plan
434-
* Used for Access Control (Permission Groups) feature gating
435-
*/
436-
export async function isOrganizationOnEnterprisePlan(organizationId: string): Promise<boolean> {
433+
async function resolveOrganizationEnterprisePlan(organizationId: string): Promise<boolean> {
437434
try {
438435
if (!isBillingEnabled) {
439436
return true
@@ -456,6 +453,17 @@ export async function isOrganizationOnEnterprisePlan(organizationId: string): Pr
456453
}
457454
}
458455

456+
/**
457+
* Check if an organization has an enterprise plan
458+
* Used for Access Control (Permission Groups) feature gating
459+
*
460+
* Request-memoized: settings renders gate several sections on the same
461+
* organization's plan, and the plan cannot change mid-render. Outside a Server
462+
* Component render React evaluates the resolver normally, so routes, tools, and
463+
* background work re-read exactly as before.
464+
*/
465+
export const isOrganizationOnEnterprisePlan = cache(resolveOrganizationEnterprisePlan)
466+
459467
/**
460468
* Entitlement for a single org-scoped enterprise feature.
461469
*
@@ -612,10 +620,16 @@ async function hasWorkspaceTierAccess(
612620
* Whether the workspace's payer is on a usable Max-or-Enterprise subscription.
613621
* Shared by the inbox (Sim Mailer), live sync, and custom sandboxes, which all
614622
* sit on the same entitlement tier.
623+
*
624+
* Request-memoized because those features are gated side by side on the same
625+
* settings render, each otherwise repeating the identical workspace and
626+
* subscription reads. The per-feature deployment and env short-circuits live in
627+
* the exported wrappers and still run per call. Outside a Server Component
628+
* render React evaluates this normally.
615629
*/
616-
async function hasMaxTierWorkspaceAccess(workspaceId: string): Promise<boolean> {
617-
return hasWorkspaceTierAccess(workspaceId, isMaxTier)
618-
}
630+
const hasMaxTierWorkspaceAccess = cache(
631+
(workspaceId: string): Promise<boolean> => hasWorkspaceTierAccess(workspaceId, isMaxTier)
632+
)
619633

620634
/**
621635
* Check whether a workspace is entitled to the inbox (Sim Mailer) feature.

‎apps/sim/lib/permissions/super-user.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { cache } from 'react'
12
import { db, dbReplica } from '@sim/db'
23
import { settings, user } from '@sim/db/schema'
34
import { eq } from 'drizzle-orm'
@@ -41,13 +42,17 @@ export async function verifyEffectiveSuperUser(userId: string): Promise<{
4142
* served from the replica: this gates features, not security-critical auth, so it
4243
* tolerates the replica's bounded staleness (admin role rarely changes). Falls back
4344
* to the primary when no replica is configured.
45+
*
46+
* Request-memoized: an account-settings render checks the same viewer in both
47+
* the layout and the page. Outside a Server Component render React evaluates
48+
* the reader normally, so routes and feature-flag lookups are unaffected.
4449
*/
45-
export async function isPlatformAdmin(userId: string): Promise<boolean> {
50+
export const isPlatformAdmin = cache(async (userId: string): Promise<boolean> => {
4651
const [row] = await dbReplica
4752
.select({ role: user.role })
4853
.from(user)
4954
.where(eq(user.id, userId))
5055
.limit(1)
5156

5257
return row?.role === 'admin'
53-
}
58+
})

‎apps/sim/lib/workspaces/permissions/utils.ts‎

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { cache } from 'react'
12
import { db } from '@sim/db'
23
import { member, permissions, user, type WorkspaceMode, workspace } from '@sim/db/schema'
34
import {
@@ -77,17 +78,12 @@ export async function getWorkspaceById(
7778
return exists ? { id: workspaceId } : null
7879
}
7980

80-
/**
81-
* Get a workspace with owner info by ID
82-
*
83-
* @param workspaceId - The workspace ID to look up
84-
* @returns The workspace with owner info if found, null otherwise
85-
*/
86-
export async function getWorkspaceWithOwner(
81+
async function selectWorkspaceWithOwner(
8782
workspaceId: string,
88-
options?: { includeArchived?: boolean; executor?: DbOrTx; forUpdate?: boolean }
83+
includeArchived: boolean,
84+
executor: DbOrTx,
85+
forUpdate: boolean
8986
): Promise<WorkspaceWithOwner | null> {
90-
const { includeArchived = false, executor = db, forUpdate = false } = options ?? {}
9187
const query = executor
9288
.select({
9389
id: workspace.id,
@@ -110,6 +106,41 @@ export async function getWorkspaceWithOwner(
110106
return ws || null
111107
}
112108

109+
/**
110+
* Request-memoized plain workspace read, keyed by id and archived visibility.
111+
* A single Server Component render pass resolves the same workspace row through
112+
* several independent gates, so without this the row is re-read once per gate.
113+
*
114+
* Outside a Server Component render React evaluates this normally and retains
115+
* nothing, so API routes and background work are unaffected.
116+
*/
117+
const readWorkspaceWithOwner = cache(
118+
(workspaceId: string, includeArchived: boolean): Promise<WorkspaceWithOwner | null> =>
119+
selectWorkspaceWithOwner(workspaceId, includeArchived, db, false)
120+
)
121+
122+
/**
123+
* Get a workspace with owner info by ID
124+
*
125+
* Transaction-scoped (`executor`) and lock-acquiring (`forUpdate`) reads
126+
* deliberately bypass {@link readWorkspaceWithOwner}: a row read inside one
127+
* caller's transaction, or under a row lock only that caller holds, must never
128+
* be handed to a later caller that took neither.
129+
*
130+
* @param workspaceId - The workspace ID to look up
131+
* @returns The workspace with owner info if found, null otherwise
132+
*/
133+
export function getWorkspaceWithOwner(
134+
workspaceId: string,
135+
options?: { includeArchived?: boolean; executor?: DbOrTx; forUpdate?: boolean }
136+
): Promise<WorkspaceWithOwner | null> {
137+
const { includeArchived = false, executor, forUpdate = false } = options ?? {}
138+
if (executor || forUpdate) {
139+
return selectWorkspaceWithOwner(workspaceId, includeArchived, executor ?? db, forUpdate)
140+
}
141+
return readWorkspaceWithOwner(workspaceId, includeArchived)
142+
}
143+
113144
/**
114145
* Resolve the effective workspace permission for a user under the governance
115146
* inheritance model: the owners/admins of the organization that owns the
@@ -131,18 +162,7 @@ export async function getEffectiveWorkspacePermission(
131162
return resolveEffectiveWorkspacePermission(userId, ws.id, ws.organizationId, executor)
132163
}
133164

134-
/**
135-
* Check workspace access for a user
136-
*
137-
* Verifies the workspace exists and the user has access to it.
138-
* Returns access level (read/write) based on ownership, explicit permissions,
139-
* and organization-admin inheritance.
140-
*
141-
* @param workspaceId - The workspace ID to check
142-
* @param userId - The user ID to check access for
143-
* @returns WorkspaceAccess object with exists, hasAccess, canWrite, and workspace data
144-
*/
145-
export async function checkWorkspaceAccess(
165+
async function resolveWorkspaceAccessForUser(
146166
workspaceId: string,
147167
userId: string
148168
): Promise<WorkspaceAccess> {
@@ -167,6 +187,25 @@ export async function checkWorkspaceAccess(
167187
return { exists: true, hasAccess, canWrite, canAdmin, workspace: ws, permission }
168188
}
169189

190+
/**
191+
* Check workspace access for a user
192+
*
193+
* Verifies the workspace exists and the user has access to it.
194+
* Returns access level (read/write) based on ownership, explicit permissions,
195+
* and organization-admin inheritance.
196+
*
197+
* Request-memoized: a Server Component render pass authorizes the same
198+
* (workspace, viewer) pair through several gates, and the answer cannot change
199+
* mid-render. Outside a render React evaluates the resolver normally, so route
200+
* handlers and background work re-read exactly as before. Takes no executor, so
201+
* no transaction-scoped or locked read can ever be memoized here.
202+
*
203+
* @param workspaceId - The workspace ID to check
204+
* @param userId - The user ID to check access for
205+
* @returns WorkspaceAccess object with exists, hasAccess, canWrite, and workspace data
206+
*/
207+
export const checkWorkspaceAccess = cache(resolveWorkspaceAccessForUser)
208+
170209
/**
171210
* Returns `provided` when it was resolved for this exact workspace, otherwise
172211
* resolves fresh. The id match is what keeps a caller from authorizing against

0 commit comments

Comments
 (0)