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: 2 additions & 0 deletions apps/realtime/src/database/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ const socketDb = drizzle(
instrumentPoolClient(
postgres(connectionString, {
prepare: false,
// See `packages/db/db.ts` — skips the per-connection pg_type roundtrip.
fetch_types: false,
idle_timeout: 10,
connect_timeout: 20,
max: 10,
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/app/api/workspaces/[id]/files/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } f
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockUploadWorkspaceFile, mockGetSharesForResources, mockRecordAudit } = vi.hoisted(() => ({
const { mockUploadWorkspaceFile, mockGetWorkspaceShares, mockRecordAudit } = vi.hoisted(() => ({
mockUploadWorkspaceFile: vi.fn(),
mockGetSharesForResources: vi.fn(),
mockGetWorkspaceShares: vi.fn(),
mockRecordAudit: vi.fn(),
}))

Expand All @@ -27,7 +27,7 @@ vi.mock('@/lib/uploads/shared/types', async (importOriginal) => {
})

vi.mock('@/lib/public-shares/share-manager', () => ({
getSharesForResources: mockGetSharesForResources,
getWorkspaceShares: mockGetWorkspaceShares,
}))

vi.mock('@/lib/posthog/server', () => posthogServerMock)
Expand Down Expand Up @@ -80,7 +80,7 @@ describe('workspace files upload route', () => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockGetSharesForResources.mockResolvedValue(new Map())
mockGetWorkspaceShares.mockResolvedValue(new Map())
mockUploadWorkspaceFile.mockResolvedValue({
id: 'file-1',
name: 'file.txt',
Expand Down
7 changes: 2 additions & 5 deletions apps/sim/app/api/workspaces/[id]/files/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { getSharesForResources } from '@/lib/public-shares/share-manager'
import { getWorkspaceShares } from '@/lib/public-shares/share-manager'
import {
FileConflictError,
listWorkspaceFiles,
Expand Down Expand Up @@ -74,10 +74,7 @@ export const GET = withRouteHandler(

const files = await listWorkspaceFiles(workspaceId, { scope })

const shares = await getSharesForResources(
'file',
files.map((file) => file.id)
)
const shares = await getWorkspaceShares('file', workspaceId)
const filesWithShares = files.map((file) => ({
...file,
share: shares.get(file.id) ?? null,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/hooks/queries/workspace-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { useFileContentSource } from '@/hooks/use-file-content-source'

const logger = createLogger('WorkspaceFilesQuery')

type WorkspaceFileQueryScope = 'active' | 'archived' | 'all'
type WorkspaceFileQueryScope = 'active' | 'archived'

/**
* Query key factories for workspace files
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/lib/api/contracts/workspace-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { inlineFileRefQuerySchema } from '@/lib/api/contracts/primitives'
import { shareRecordSchema } from '@/lib/api/contracts/public-shares'
import { defineRouteContract } from '@/lib/api/contracts/types'

export const workspaceFileScopeSchema = z.enum(['active', 'archived', 'all'])
/**
* Client-reachable listing scopes. `all` is deliberately excluded: it drops the
* `deleted_at` predicate, so it cannot use the partial index that serves the
* other two and degrades to a full workspace scan. No client requests it, and
* server-side callers reach that scope directly rather than over the wire.
*/
export const workspaceFileScopeSchema = z.enum(['active', 'archived'])

export const workspaceFilesParamsSchema = z.object({
id: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'),
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/lib/billing/core/billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ const {
mockComputeDailyRefreshConsumed,
mockEnsureUserStatsExists,
mockGetBillingPeriodUsageCost,
mockGetBillingPeriodUsageCostWithSourceSubset,
mockGetHighestPriorityPersonalSubscription,
mockGetHighestPrioritySubscription,
mockResolveBillingInterval,
} = vi.hoisted(() => ({
mockComputeDailyRefreshConsumed: vi.fn(),
mockEnsureUserStatsExists: vi.fn(),
mockGetBillingPeriodUsageCost: vi.fn(),
mockGetBillingPeriodUsageCostWithSourceSubset: vi.fn(),
mockGetHighestPriorityPersonalSubscription: vi.fn(),
mockGetHighestPrioritySubscription: vi.fn(),
mockResolveBillingInterval: vi.fn(),
Expand All @@ -35,6 +37,7 @@ vi.mock('@/lib/billing/core/usage', () => ({
vi.mock('@/lib/billing/core/usage-log', () => ({
COPILOT_USAGE_SOURCES: ['copilot'],
getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost,
getBillingPeriodUsageCostWithSourceSubset: mockGetBillingPeriodUsageCostWithSourceSubset,
}))

vi.mock('@/lib/billing/credits/daily-refresh', () => ({
Expand All @@ -50,7 +53,7 @@ describe('getPersonalBillingSummary', () => {
mockEnsureUserStatsExists.mockResolvedValue(undefined)
mockResolveBillingInterval.mockReturnValue('year')
mockComputeDailyRefreshConsumed.mockResolvedValue(3)
mockGetBillingPeriodUsageCost.mockResolvedValueOnce(2).mockResolvedValueOnce(1)
mockGetBillingPeriodUsageCostWithSourceSubset.mockResolvedValue({ total: 2, subset: 1 })
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
id: 'personal-sub',
referenceId: 'viewer-a',
Expand Down
14 changes: 8 additions & 6 deletions apps/sim/lib/billing/core/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
resolveBillingInterval,
} from '@/lib/billing/core/subscription'
import { ensureUserStatsExists } from '@/lib/billing/core/usage'
import { COPILOT_USAGE_SOURCES, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
import {
COPILOT_USAGE_SOURCES,
getBillingPeriodUsageCost,
getBillingPeriodUsageCostWithSourceSubset,
} from '@/lib/billing/core/usage-log'
import {
computeDailyRefreshConsumed,
getOrgMemberRefreshBounds,
Expand Down Expand Up @@ -429,15 +433,13 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
personalSubscription?.periodStart && personalSubscription.periodEnd
? { start: personalSubscription.periodStart, end: personalSubscription.periodEnd }
: defaultBillingPeriod()
const [ledgerUsage, copilotLedgerUsage] = await Promise.all([
getBillingPeriodUsageCost({ type: 'user', id: userId }, billingPeriod, undefined, executor),
getBillingPeriodUsageCost(
const { total: ledgerUsage, subset: copilotLedgerUsage } =
await getBillingPeriodUsageCostWithSourceSubset(
{ type: 'user', id: userId },
billingPeriod,
COPILOT_USAGE_SOURCES,
executor
),
])
)

const hasPersonalUsageSnapshot =
Boolean(personalSubscription) && isPro(plan) && stats.proPeriodCostSnapshotAt !== null
Expand Down
34 changes: 34 additions & 0 deletions apps/sim/lib/billing/core/usage-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,40 @@ export async function getBillingPeriodUsageCost(
return Number.parseFloat(row?.cost ?? '0')
}

/**
* Period total plus the portion attributable to `source`, in a single scan.
*
* Two separate aggregates over the identical row set double the work and, because
* they are separate statements, can observe different snapshots — which makes the
* subset exceeding the total representable. One statement rules that out.
*/
export async function getBillingPeriodUsageCostWithSourceSubset(
billingEntity: BillingEntity,
billingPeriod: { start: Date; end: Date },
source: UsageLogSource[],
executor: DbClient = db
): Promise<{ total: number; subset: number }> {
const [row] = await executor
.select({
total: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
subset: sql<string>`COALESCE(SUM(${usageLog.cost}) FILTER (WHERE ${inArray(usageLog.source, source)}), 0)`,
})
.from(usageLog)
.where(
and(
eq(usageLog.billingEntityType, billingEntity.type),
eq(usageLog.billingEntityId, billingEntity.id),
eq(usageLog.billingPeriodStart, billingPeriod.start),
eq(usageLog.billingPeriodEnd, billingPeriod.end)
)
)

return {
total: Number.parseFloat(row?.total ?? '0'),
subset: Number.parseFloat(row?.subset ?? '0'),
}
}

export async function getBillingPeriodUsageCostByUser(
billingEntity: BillingEntity,
billingPeriod: { start: Date; end: Date },
Expand Down
99 changes: 96 additions & 3 deletions apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { dbChainMockFns, drizzleOrmMock } from '@sim/testing/mocks'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
ensureWorkspaceFileFolderPath: vi.fn(),
listWorkspaceFileFolders: vi.fn(),
getWorkspaceFileByName: vi.fn(),
listWorkspaceFiles: vi.fn(),
uploadWorkspaceFile: vi.fn(),
}))

Expand All @@ -15,11 +15,10 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () =>

vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
getWorkspaceFileByName: mocks.getWorkspaceFileByName,
listWorkspaceFiles: mocks.listWorkspaceFiles,
uploadWorkspaceFile: mocks.uploadWorkspaceFile,
}))

import { ensureWorkflowAliasBacking } from './workflow-alias-backing'
import { cleanupWorkflowAliasBacking, ensureWorkflowAliasBacking } from './workflow-alias-backing'

describe('workflow alias backing', () => {
beforeEach(() => {
Expand Down Expand Up @@ -79,4 +78,98 @@ describe('workflow alias backing', () => {
expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled()
expect(result.changelogFile).toMatchObject({ id: 'file-existing' })
})

describe('cleanupWorkflowAliasBacking', () => {
/**
* Folder paths resolve independently of `deletedAt`, so a live file parented
* to an archived folder still resolves to a backing path. The archived
* `.changelogs` folder below therefore has to participate in file ownership
* while staying out of the set of folders that get archived.
*/
const folders = [
{ id: 'changelog-live', path: '.changelogs', deletedAt: null },
{ id: 'changelog-archived', path: '.changelogs', deletedAt: new Date() },
{ id: 'plans-wf1', path: '.plans/wf_1', deletedAt: null },
{ id: 'plans-wf1-nested', path: '.plans/wf_1/nested', deletedAt: null },
{ id: 'plans-wf1-archived', path: '.plans/wf_1/old', deletedAt: new Date() },
{ id: 'plans-wf2', path: '.plans/wf_2', deletedAt: null },
{ id: 'unrelated', path: 'documents', deletedAt: null },
]

beforeEach(() => {
mocks.listWorkspaceFileFolders.mockResolvedValue(folders)
})

it('scopes file ownership by folder id, including archived folders', async () => {
await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })

const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values)

expect(inArrayValues).toContainEqual(['plans-wf1', 'plans-wf1-nested', 'plans-wf1-archived'])
expect(inArrayValues).toContainEqual(['changelog-live', 'changelog-archived'])
})

it('archives only live folders owned by the workflow', async () => {
await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })

const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values)

expect(inArrayValues).toContainEqual(['plans-wf1', 'plans-wf1-nested'])
expect(inArrayValues.flat()).not.toContain('plans-wf2')
expect(inArrayValues.flat()).not.toContain('unrelated')
})

it('matches the changelog by the workflow-scoped filename', async () => {
await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })

expect(drizzleOrmMock.eq).toHaveBeenCalledWith(expect.anything(), 'wf_1.md')
})

it('restricts the update to workspace-context files', async () => {
await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })

expect(drizzleOrmMock.eq).toHaveBeenCalledWith(expect.anything(), 'workspace')
})

it('still archives the changelog when the workflow has no plans folder', async () => {
mocks.listWorkspaceFileFolders.mockResolvedValue([
{ id: 'changelog-live', path: '.changelogs', deletedAt: null },
{ id: 'plans-wf2', path: '.plans/wf_2', deletedAt: null },
])

await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })

const inArrayValues = drizzleOrmMock.inArray.mock.calls.map(([, values]) => values)
expect(inArrayValues).toContainEqual(['changelog-live'])
expect(inArrayValues.flat()).not.toContain('plans-wf2')
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
})

/**
* The decisive guard: `and()` drops `undefined`, so an ownership clause that
* resolved to nothing would leave a WHERE matching every file in the
* workspace. No ownership must mean no UPDATE is issued at all.
*/
it('issues no update at all when the workflow owns no backing folders', async () => {
mocks.listWorkspaceFileFolders.mockResolvedValue([
{ id: 'unrelated', path: 'documents', deletedAt: null },
])

const result = await cleanupWorkflowAliasBacking({
workspaceId: 'workspace-1',
workflowId: 'wf_missing',
})

expect(dbChainMockFns.update).not.toHaveBeenCalled()
expect(drizzleOrmMock.inArray).not.toHaveBeenCalled()
expect(result).toEqual({ files: 0, folders: 0 })
})

it('never archives files belonging to another workflow', async () => {
await cleanupWorkflowAliasBacking({ workspaceId: 'workspace-1', workflowId: 'wf_1' })

const inArrayValues = drizzleOrmMock.inArray.mock.calls.flatMap(([, values]) => values)
expect(inArrayValues).not.toContain('plans-wf2')
})
})
})
Loading
Loading