Skip to content

Commit 8d828cc

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/docs-generator-hidden-params
2 parents b8632d2 + b5b336a commit 8d828cc

23 files changed

Lines changed: 260 additions & 71 deletions

File tree

.agents/skills/add-feature-flag/SKILL.md

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
---
22
name: add-feature-flag
3-
description: Add a runtime feature flag (AppConfig-backed on prod, secret fallback off-prod), global by default or optionally gated by org id, user id, or platform admin
3+
description: Add a runtime feature flag (AppConfig-backed on prod, secret fallback off-prod), global by default or optionally gated by workspace id, org id, user id, or platform admin
44
argument-hint: <flag-name>
55
---
66

77
# Add Feature Flag Skill
88

9-
You add a **runtime feature flag** to Sim that can change on prod with no redeploy (AWS AppConfig). Prefer a global on/off flag unless the rollout actually needs per-organization, per-user, or platform-admin targeting. When AppConfig isn't the source of truth, the flag falls back to a single **secret** (on/off only).
9+
You add a **runtime feature flag** to Sim that can change on prod with no redeploy (AWS AppConfig). Prefer a global on/off flag unless the rollout actually needs per-workspace, per-organization, per-user, or platform-admin targeting. When AppConfig isn't the source of truth, the flag falls back to a single **secret** (on/off only).
1010

1111
## When to use this vs `env-flags.ts`
1212

13-
- **Feature flag** (`@/lib/core/config/feature-flags.ts`): runtime global on/off by default, optionally scoped by `userId`/`orgId`/admin. This skill.
13+
- **Feature flag** (`@/lib/core/config/feature-flags.ts`): runtime global on/off by default, optionally scoped by `workspaceId`/`userId`/`orgId`/admin. This skill.
1414
- **Env flag** (`@/lib/core/config/env-flags.ts`): deploy-time capability/environment detection (`isProd`, `isHosted`, `isBillingEnabled`). A module-load boolean. **Do not add gated flags here.**
1515

1616
If the user wants a fixed per-deployment toggle, send them to `env-flags.ts` instead.
@@ -21,10 +21,11 @@ A flag's **gating rule lives only in the hosted AppConfig document**. It is ON f
2121

2222
```ts
2323
interface FeatureFlagRule {
24-
enabled?: boolean // global default for everyone
25-
orgIds?: string[] // allowlisted organization ids
26-
userIds?: string[] // allowlisted user ids
27-
adminEnabled?: boolean // platform admins (user.role === 'admin')
24+
enabled?: boolean // global default for everyone
25+
workspaceIds?: string[] // allowlisted workspace ids
26+
orgIds?: string[] // allowlisted organization ids
27+
userIds?: string[] // allowlisted user ids
28+
adminEnabled?: boolean // platform admins (user.role === 'admin')
2829
}
2930
```
3031

@@ -34,10 +35,10 @@ Critically, **none of this is expressible in code** — gating (especially `admi
3435

3536
1. **Confirm the granularity before editing code.** If the user has not already specified it, stop and ask:
3637

37-
> Should `<flag-name>` be a global on/off flag (recommended), or does it need rollout targeting by organization, user, and/or platform admin?
38+
> Should `<flag-name>` be a global on/off flag (recommended), or does it need rollout targeting by workspace, organization, user, and/or platform admin?
3839
39-
- Recommend **global**. Do not infer scoped gating merely because the call site already has a user or organization id.
40-
- If the user chooses scoped gating but does not name the dimensions, ask which of organization, user, and platform admin it needs. Wire only the selected dimensions.
40+
- Recommend **global**. Do not infer scoped gating merely because the call site already has a workspace, user, or organization id.
41+
- If the user chooses scoped gating but does not name the dimensions, ask which of workspace, organization, user, and platform admin it needs. Wire only the selected dimensions.
4142
- If the user wants a fixed per-deployment toggle rather than a runtime AppConfig flag, use `env-flags.ts` instead.
4243

4344
2. **Define the flag.** Add one entry to the `FEATURE_FLAGS` registry in `apps/sim/lib/core/config/feature-flags.ts`. Each entry is the flag's whole definition — name (kebab-case key), `description`, and the `fallback` secret consulted when AppConfig isn't the source of truth (truthy ⇒ on globally):
@@ -51,7 +52,7 @@ Critically, **none of this is expressible in code** — gating (especially `admi
5152
}
5253
```
5354

54-
`fallback` is the env/secret key (typed as `keyof typeof env`), so add `<FLAG_SECRET>` to `apps/sim/lib/core/config/env.ts` first (and the deployment's secret store) — it won't typecheck otherwise. Do **not** add org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `<flag-name>` a valid `FeatureFlagName`.
55+
`fallback` is the env/secret key (typed as `keyof typeof env`), so add `<FLAG_SECRET>` to `apps/sim/lib/core/config/env.ts` first (and the deployment's secret store) — it won't typecheck otherwise. Do **not** add workspace/org/user/admin defaults here — that gating exists only in AppConfig. Adding the entry makes `<flag-name>` a valid `FeatureFlagName`.
5556

5657
3. **Gate the call site at the chosen granularity.** For the recommended global mode, pass no context:
5758

@@ -70,17 +71,17 @@ Critically, **none of this is expressible in code** — gating (especially `admi
7071
```ts
7172
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
7273

73-
if (await isFeatureEnabled('<flag-name>', { userId, orgId })) {
74+
if (await isFeatureEnabled('<flag-name>', { workspaceId, userId, orgId })) {
7475
// gated behavior
7576
}
7677
```
7778

78-
- Organization targeting uses `orgId`; user and platform-admin targeting require `userId`.
79+
- Workspace targeting uses `workspaceId`; organization targeting uses `orgId`; user and platform-admin targeting require `userId`.
7980
- Missing ids are fine — a clause with no matching id is skipped; with no `userId`, the admin clause resolves to `false` without a DB read.
8081
- Admin routes that already know the caller is an admin may pass `{ userId, isAdmin: true }` to skip the role lookup.
8182
- **Client/UI flags:** resolve server-side (in a server component, route, or loader) and pass the boolean down as a prop. There is no client AppConfig.
8283

83-
4. **(Prod) configure in AppConfig.** The infra `feature-flags` profile schema is permissive, so a new flag needs **no infra change**. Operators add the flag to the hosted `feature-flags` document using `enabled` for global rollout or only the selected `orgIds`/`userIds`/`adminEnabled` clauses for scoped rollout, then start a `sim-<env>-fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). The fallback secret only applies when AppConfig is disabled.
84+
4. **(Prod) configure in AppConfig.** The infra `feature-flags` profile schema is permissive, so a new flag needs **no infra change**. Operators add the flag to the hosted `feature-flags` document using `enabled` for global rollout or only the selected `workspaceIds`/`orgIds`/`userIds`/`adminEnabled` clauses for scoped rollout, then start a `sim-<env>-fast` deployment (see the AppConfig runbook in the infra README — same flow as `access-control`). The fallback secret only applies when AppConfig is disabled.
8485

8586
5. **Test.** Add a case to `apps/sim/lib/core/config/feature-flags.test.ts` that matches the chosen granularity. For a global flag, exercise `isFeatureEnabled('<flag-name>')` with an AppConfig `enabled` rule and toggle the fallback secret for the off-AppConfig path. For scoped rollout, cover only the selected clauses and mock `isPlatformAdmin` when testing `adminEnabled`.
8687

@@ -90,6 +91,6 @@ Critically, **none of this is expressible in code** — gating (especially `admi
9091

9192
- Flag keys are `kebab-case`.
9293
- Never read flags via raw `fetch` or a new AppConfig client — always go through `isFeatureEnabled` / `getFeatureFlags`.
93-
- Never bake gating into code. The fallback is a single boolean secret; org/user/admin scoping is AppConfig-only.
94+
- Never bake gating into code. The fallback is a single boolean secret; workspace/org/user/admin scoping is AppConfig-only.
9495
- Never add or propagate request context unless the user chose scoped rollout.
9596
- The admin check reads the DB **replica** (`dbReplica`) and is resolved lazily, so an admin-gated flag adds at most one cheap replica read, and only when `adminEnabled` is the deciding clause.

apps/sim/app/api/workspaces/[id]/permissions/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ function queuePersonalWorkspace(
7373
) {
7474
const workspaceRow = { ownerId: OWNER_ID, billedAccountUserId, organizationId: null }
7575
queueTableRows(schemaMock.workspace, [workspaceRow])
76-
/** The in-transaction re-read of the same row, taken `FOR UPDATE`. */
76+
/** The in-transaction re-read of the same row, taken `FOR NO KEY UPDATE`. */
7777
permissionsMockFns.mockGetWorkspaceWithOwner.mockResolvedValue({
7878
id: WORKSPACE_ID,
7979
...workspaceRow,

apps/sim/lib/billing/storage/payer-transfer.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,7 +769,7 @@ describe('changeOrganizationWorkspaceBilledAccountsInTx', () => {
769769
expect(returning).toHaveBeenCalledWith({ id: 'workspace.id' })
770770
expect(select).toHaveBeenCalledWith({ id: 'workspace.id' })
771771
expect(orderBy).toHaveBeenCalledTimes(1)
772-
expect(lock).toHaveBeenCalledWith('update')
772+
expect(lock).toHaveBeenCalledWith('no key update')
773773
expect(lock.mock.invocationCallOrder[0]).toBeLessThan(update.mock.invocationCallOrder[0])
774774
expect(execute).not.toHaveBeenCalled()
775775
})

apps/sim/lib/billing/storage/payer-transfer.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,17 @@ async function getExactWorkspaceStorageBytes(tx: DbOrTx, workspaceId: string): P
125125
/**
126126
* Locks a payer row and returns its current aggregate. A missing source can be
127127
* historical drift and is represented as `null`; callers must reject a
128-
* missing destination.
128+
* missing destination. `FOR NO KEY UPDATE` avoids upgrading the implicit
129+
* foreign-key `FOR KEY SHARE` this transaction may already hold; see the
130+
* module header of `lib/billing/storage/tracking.ts`.
129131
*/
130132
async function lockStoragePayer(tx: DbOrTx, payer: BillingEntity): Promise<number | null> {
131133
if (payer.type === 'organization') {
132134
const [row] = await tx
133135
.select({ storageUsedBytes: organization.storageUsedBytes })
134136
.from(organization)
135137
.where(eq(organization.id, payer.id))
136-
.for('update')
138+
.for('no key update')
137139
.limit(1)
138140
return row?.storageUsedBytes ?? null
139141
}
@@ -142,7 +144,7 @@ async function lockStoragePayer(tx: DbOrTx, payer: BillingEntity): Promise<numbe
142144
.select({ storageUsedBytes: userStats.storageUsedBytes })
143145
.from(userStats)
144146
.where(eq(userStats.userId, payer.id))
145-
.for('update')
147+
.for('no key update')
146148
.limit(1)
147149
return row?.storageUsedBytes ?? null
148150
}
@@ -257,7 +259,7 @@ async function lockStoragePayers(
257259
.from(userStats)
258260
.where(inArray(userStats.userId, userIds))
259261
.orderBy(asc(userStats.userId))
260-
.for('update')
262+
.for('no key update')
261263
for (const row of rows) {
262264
usageByKey.set(getPayerKey({ type: 'user', id: row.id }), row.storageUsedBytes)
263265
}
@@ -269,7 +271,7 @@ async function lockStoragePayers(
269271
.from(organization)
270272
.where(inArray(organization.id, organizationIds))
271273
.orderBy(asc(organization.id))
272-
.for('update')
274+
.for('no key update')
273275
for (const row of rows) {
274276
usageByKey.set(getPayerKey({ type: 'organization', id: row.id }), row.storageUsedBytes)
275277
}
@@ -371,7 +373,7 @@ export async function changeWorkspaceStoragePayersInTx(
371373
.from(workspace)
372374
.where(inArray(workspace.id, workspaceIds))
373375
.orderBy(asc(workspace.id))
374-
.for('update')
376+
.for('no key update')
375377

376378
const workspaceById = new Map(lockedWorkspaces.map((row) => [row.id, row]))
377379
for (const workspaceId of workspaceIds) {
@@ -562,7 +564,7 @@ export async function changeOrganizationWorkspaceBilledAccountsInTx(
562564
)
563565
)
564566
.orderBy(asc(workspace.id))
565-
.for('update')
567+
.for('no key update')
566568

567569
const rows = await tx
568570
.update(workspace)
@@ -604,7 +606,7 @@ export async function changeWorkspaceStoragePayerInTx(
604606
})
605607
.from(workspace)
606608
.where(eq(workspace.id, params.workspaceId))
607-
.for('update')
609+
.for('no key update')
608610
.limit(1)
609611

610612
if (!lockedWorkspace) {

apps/sim/lib/billing/storage/tracking.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const {
1313
mockMaybeNotifyLimit,
1414
mockOrderedLockRows,
1515
mockSql,
16+
mockTxFor,
1617
mockTxFrom,
1718
mockTxLimit,
1819
mockTxOrderBy,
@@ -32,6 +33,7 @@ const {
3233
mockMaybeNotifyLimit: vi.fn(),
3334
mockOrderedLockRows: { queue: [] as unknown[][] },
3435
mockSql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })),
36+
mockTxFor: vi.fn(),
3537
mockTxFrom: vi.fn(),
3638
mockTxLimit: vi.fn(),
3739
mockTxOrderBy: vi.fn(),
@@ -120,6 +122,42 @@ const ORG_CONTEXT: StorageBillingContext = {
120122
customStorageLimitGB: null,
121123
}
122124

125+
const USER_CONTEXT: StorageBillingContext = {
126+
workspaceId: 'workspace-1',
127+
billedAccountUserId: 'workspace-owner',
128+
billingEntity: { type: 'user', id: 'workspace-owner' },
129+
plan: 'pro',
130+
customStorageLimitGB: null,
131+
}
132+
133+
/**
134+
* Both payer kinds. The workspace lock is shared, but the payer lock branches
135+
* to a different table per kind, so a lock-mode regression on only one of them
136+
* has to fail a test.
137+
*/
138+
const PAYER_CASES = [
139+
{
140+
label: 'organization',
141+
context: ORG_CONTEXT,
142+
workspaceRow: {
143+
billedAccountUserId: 'workspace-owner',
144+
organizationId: 'workspace-org' as string | null,
145+
storageUsedBytes: 1_000,
146+
},
147+
payerLockRows: [{ id: 'workspace-org', storageUsedBytes: 1_000 }],
148+
},
149+
{
150+
label: 'user',
151+
context: USER_CONTEXT,
152+
workspaceRow: {
153+
billedAccountUserId: 'workspace-owner',
154+
organizationId: null as string | null,
155+
storageUsedBytes: 1_000,
156+
},
157+
payerLockRows: [{ id: 'workspace-owner', storageUsedBytes: 1_000 }],
158+
},
159+
] as const
160+
123161
beforeAll(() => {
124162
setEnvFlags({ isBillingEnabled: true })
125163
})
@@ -138,9 +176,10 @@ describe('workspace storage counter mutations', () => {
138176

139177
mockOrderedLockRows.queue = []
140178
mockTxSelect.mockReturnValue({ from: mockTxFrom })
179+
mockTxFor.mockReturnValue({ limit: mockTxLimit })
141180
mockTxFrom.mockReturnValue({
142181
where: vi.fn(() => ({
143-
for: vi.fn(() => ({ limit: mockTxLimit })),
182+
for: mockTxFor,
144183
limit: mockTxLimit,
145184
orderBy: mockTxOrderBy,
146185
})),
@@ -183,6 +222,39 @@ describe('workspace storage counter mutations', () => {
183222
expect(mockMaybeNotifyLimit).not.toHaveBeenCalled()
184223
})
185224

225+
/**
226+
* `FOR UPDATE` on these rows deadlocked in production: `workspace`,
227+
* `organization`, and `user_stats` are foreign-key parents, so the calling
228+
* transaction already holds an implicit `FOR KEY SHARE` on them from the
229+
* billable child row it just wrote, and the stronger lock is an upgrade that
230+
* two concurrent uploads take on each other. `FOR NO KEY UPDATE` still
231+
* conflicts with itself, so the ledgers stay serialized.
232+
*/
233+
it.each(PAYER_CASES)(
234+
'locks the workspace and its $label payer as FOR NO KEY UPDATE',
235+
async ({ context, workspaceRow }) => {
236+
mockWorkspaceRow.current = { ...workspaceRow }
237+
238+
await incrementStorageUsageForBillingContextInTx(mockTx as unknown as DbOrTx, context, 100)
239+
240+
expect(mockTxFor.mock.calls).toEqual([['no key update'], ['no key update']])
241+
}
242+
)
243+
244+
it.each(PAYER_CASES)(
245+
'locks batched workspace and $label payer ledgers as FOR NO KEY UPDATE',
246+
async ({ context, workspaceRow, payerLockRows }) => {
247+
mockOrderedLockRows.queue = [[{ id: 'workspace-1', ...workspaceRow }], [...payerLockRows]]
248+
249+
await applyStorageUsageDeltasInTx(mockTx as unknown as DbOrTx, {
250+
workspaceDeltas: [{ context, deltaBytes: 100 }],
251+
legacyDeltas: [],
252+
})
253+
254+
expect(mockTxOrderedFor.mock.calls).toEqual([['no key update'], ['no key update']])
255+
}
256+
)
257+
186258
it('serializes quota admission on the locked payer ledger', async () => {
187259
mockGetStorageLimitForBillingContext.mockReturnValue(1_050)
188260
mockTxLimit

apps/sim/lib/billing/storage/tracking.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
/**
22
* Storage usage tracking for durable workspace and payer ledgers.
3+
*
4+
* Every row lock here is `FOR NO KEY UPDATE`, never `FOR UPDATE`. The
5+
* `workspace`, `organization`, and `user_stats` rows these transactions lock
6+
* are foreign-key parents (49 tables reference `workspace` alone), so any
7+
* insert or update of a child row — a `workspace_files` row in this very
8+
* transaction — implicitly takes `FOR KEY SHARE` on the parent first. A later
9+
* `FOR UPDATE` on the same row is then a lock upgrade, and two concurrent
10+
* uploads or deletes in one workspace deadlock on it. `FOR NO KEY UPDATE`
11+
* does not conflict with `FOR KEY SHARE`, yet still conflicts with itself and
12+
* with `FOR UPDATE`, so writers remain serialized against each other and
13+
* against payer transfers. It is exactly the lock a plain `UPDATE` of these
14+
* non-key counters takes anyway. The only key columns on these tables are
15+
* `workspace.id`, `workspace.inbox_provider_id`, `organization.id`,
16+
* `user_stats.id`, and `user_stats.user_id`, and no path under these locks
17+
* writes any of them or deletes a locked row.
318
*/
419

520
import { organization, userStats, workspace } from '@sim/db/schema'
@@ -124,6 +139,7 @@ async function mutateStorageUsage(
124139

125140
/**
126141
* Locks and reads the payer ledger after the workspace row has been locked.
142+
* `FOR NO KEY UPDATE` for the reason documented at the top of this module.
127143
*/
128144
async function lockStorageUsageForMutation(
129145
tx: DbOrTx,
@@ -134,7 +150,7 @@ async function lockStorageUsageForMutation(
134150
.select({ storageUsedBytes: organization.storageUsedBytes })
135151
.from(organization)
136152
.where(eq(organization.id, billingEntity.id))
137-
.for('update')
153+
.for('no key update')
138154
.limit(1)
139155
if (!row) throw new Error(`Storage payer organization:${billingEntity.id} not found`)
140156
return row.storageUsedBytes
@@ -144,7 +160,7 @@ async function lockStorageUsageForMutation(
144160
.select({ storageUsedBytes: userStats.storageUsedBytes })
145161
.from(userStats)
146162
.where(eq(userStats.userId, billingEntity.id))
147-
.for('update')
163+
.for('no key update')
148164
.limit(1)
149165
if (!row) throw new Error(`Storage payer user:${billingEntity.id} not found`)
150166
return row.storageUsedBytes
@@ -242,7 +258,7 @@ export async function applyStorageUsageDeltasInTx(
242258
.from(workspace)
243259
.where(inArray(workspace.id, workspaceIds))
244260
.orderBy(asc(workspace.id))
245-
.for('update')
261+
.for('no key update')
246262
: []
247263
const workspaceById = new Map(lockedWorkspaces.map((row) => [row.id, row]))
248264

@@ -318,7 +334,7 @@ export async function applyStorageUsageDeltasInTx(
318334
.from(userStats)
319335
.where(inArray(userStats.userId, userIds))
320336
.orderBy(asc(userStats.userId))
321-
.for('update')
337+
.for('no key update')
322338
for (const row of rows) {
323339
payerUsageByKey.set(getPayerKey({ type: 'user', id: row.id }), row.storageUsedBytes)
324340
}
@@ -329,7 +345,7 @@ export async function applyStorageUsageDeltasInTx(
329345
.from(organization)
330346
.where(inArray(organization.id, organizationIds))
331347
.orderBy(asc(organization.id))
332-
.for('update')
348+
.for('no key update')
333349
for (const row of rows) {
334350
payerUsageByKey.set(getPayerKey({ type: 'organization', id: row.id }), row.storageUsedBytes)
335351
}
@@ -439,7 +455,7 @@ async function mutateWorkspaceStorageUsage(
439455
})
440456
.from(workspace)
441457
.where(eq(workspace.id, workspaceId))
442-
.for('update')
458+
.for('no key update')
443459
.limit(1)
444460

445461
if (!workspacePayer) {

apps/sim/lib/copilot/tools/handlers/materialize-file.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ async function executeSave(
140140

141141
try {
142142
transition = await db.transaction(async (tx) => {
143-
await tx.execute(sql`SELECT 1 FROM workspace WHERE id = ${workspaceId} FOR UPDATE`)
143+
/** `FOR NO KEY UPDATE`: see the module header of `lib/billing/storage/tracking.ts`. */
144+
await tx.execute(sql`SELECT 1 FROM workspace WHERE id = ${workspaceId} FOR NO KEY UPDATE`)
144145

145146
const [updated] = await tx
146147
.update(workspaceFiles)

0 commit comments

Comments
 (0)