Skip to content

Commit 78e840b

Browse files
Merge remote-tracking branch 'origin/staging' into feat/table-views
# Conflicts: # packages/db/migrations/meta/0271_snapshot.json # packages/db/migrations/meta/_journal.json
2 parents caa192c + 02311ca commit 78e840b

21 files changed

Lines changed: 18330 additions & 211 deletions

File tree

apps/realtime/src/database/operations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ const socketDb = drizzle(
214214
instrumentPoolClient(
215215
postgres(connectionString, {
216216
prepare: false,
217+
// See `packages/db/db.ts` — skips the per-connection pg_type roundtrip.
218+
fetch_types: false,
217219
idle_timeout: 10,
218220
connect_timeout: 20,
219221
max: 10,

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import { authMockFns, permissionsMock, permissionsMockFns, posthogServerMock } f
77
import { NextRequest } from 'next/server'
88
import { beforeEach, describe, expect, it, vi } from 'vitest'
99

10-
const { mockUploadWorkspaceFile, mockGetSharesForResources, mockRecordAudit } = vi.hoisted(() => ({
10+
const { mockUploadWorkspaceFile, mockGetWorkspaceShares, mockRecordAudit } = vi.hoisted(() => ({
1111
mockUploadWorkspaceFile: vi.fn(),
12-
mockGetSharesForResources: vi.fn(),
12+
mockGetWorkspaceShares: vi.fn(),
1313
mockRecordAudit: vi.fn(),
1414
}))
1515

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

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

3333
vi.mock('@/lib/posthog/server', () => posthogServerMock)
@@ -80,7 +80,7 @@ describe('workspace files upload route', () => {
8080
vi.clearAllMocks()
8181
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
8282
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
83-
mockGetSharesForResources.mockResolvedValue(new Map())
83+
mockGetWorkspaceShares.mockResolvedValue(new Map())
8484
mockUploadWorkspaceFile.mockResolvedValue({
8585
id: 'file-1',
8686
name: 'file.txt',

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from '@/lib/core/utils/stream-limits'
1717
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1818
import { captureServerEvent } from '@/lib/posthog/server'
19-
import { getSharesForResources } from '@/lib/public-shares/share-manager'
19+
import { getWorkspaceShares } from '@/lib/public-shares/share-manager'
2020
import {
2121
FileConflictError,
2222
listWorkspaceFiles,
@@ -74,10 +74,7 @@ export const GET = withRouteHandler(
7474

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

77-
const shares = await getSharesForResources(
78-
'file',
79-
files.map((file) => file.id)
80-
)
77+
const shares = await getWorkspaceShares('file', workspaceId)
8178
const filesWithShares = files.map((file) => ({
8279
...file,
8380
share: shares.get(file.id) ?? null,

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ interface UseTableParams {
3232
workspaceId: string
3333
tableId: string
3434
queryOptions: QueryOptions
35+
/**
36+
* Holds the rows query until the caller's filter/sort are settled. The wrapper
37+
* resolves the active view asynchronously, so without this the first fetch runs
38+
* against an empty filter and the grid paints the unfiltered set before
39+
* refetching — a visible flash of the wrong rows on every load that adopts a
40+
* default view or opens a `?table-view=` link.
41+
*/
42+
rowsEnabled?: boolean
3543
}
3644

3745
interface FetchNextPageResult {
@@ -88,7 +96,12 @@ export interface UseTableReturn {
8896
* stays in the `Table` component — moving it here would push every keystroke
8997
* through this hook's return value and re-render everything.
9098
*/
91-
export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams): UseTableReturn {
99+
export function useTable({
100+
workspaceId,
101+
tableId,
102+
queryOptions,
103+
rowsEnabled = true,
104+
}: UseTableParams): UseTableReturn {
92105
const queryClient = useQueryClient()
93106
const { data: tableData, isLoading: isLoadingTable } = useTableQuery(workspaceId, tableId)
94107

@@ -115,7 +128,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams)
115128
pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT,
116129
filter,
117130
sort: queryOptions.sort,
118-
enabled: Boolean(workspaceId && tableId),
131+
enabled: Boolean(workspaceId && tableId) && rowsEnabled,
119132
})
120133

121134
const rows = useMemo<TableRow[]>(

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,14 @@ export function Table({
358358
((previousName: string, newName: string) => void) | null
359359
>(null)
360360

361+
// Declared before `useTable`: the rows query is gated on `viewsLoaded`, since a
362+
// view owns the filter/sort the query runs with.
363+
const { data: views = NO_VIEWS, isSuccess: viewsLoaded } = useTableViews({
364+
workspaceId,
365+
tableId,
366+
enabled: viewsEnabled,
367+
})
368+
361369
// Single source of truth for `useTable` — drives both the grid render and
362370
// the wrapper's slideouts/modals. The grid receives the bundle as props.
363371
const {
@@ -373,12 +381,10 @@ export function Table({
373381
workspaceId,
374382
tableId,
375383
queryOptions,
376-
})
377-
378-
const { data: views = NO_VIEWS, isSuccess: viewsLoaded } = useTableViews({
379-
workspaceId,
380-
tableId,
381-
enabled: viewsEnabled,
384+
// Without this the first fetch runs against an empty filter and the grid
385+
// paints the unfiltered set before refetching. Gates only the first pass —
386+
// `viewsLoaded` stays true after, so later filter edits fetch immediately.
387+
rowsEnabled: !viewsEnabled || viewsLoaded,
382388
})
383389
const createViewMutation = useCreateTableView({ workspaceId, tableId })
384390
const updateViewMutation = useUpdateTableView({ workspaceId, tableId })
@@ -468,7 +474,14 @@ export function Table({
468474
// back to "All" without touching state, for the same reason. An explicit
469475
// `?sort=` alongside `?view=` also wins over the view's stored sort.
470476
seededViewIdRef.current = activeView?.id ?? null
471-
if (activeView) applyViewConfig(activeView.config, localWork())
477+
if (activeView) {
478+
applyViewConfig(activeView.config, localWork())
479+
} else {
480+
// Nothing to apply, but the URL still names a view that no longer exists.
481+
// Rewrite it so a stale bookmark can't be copied on, and so the param
482+
// matches the All the UI is already showing.
483+
setTableParams({ view: ALL_VIEW_PARAM })
484+
}
472485
return
473486
}
474487

@@ -590,13 +603,16 @@ export function Table({
590603
const handlePersistLayout = useCallback(
591604
(patch: TableMetadata) => {
592605
liveLayoutRef.current = { ...liveLayoutRef.current, ...patch }
593-
if (!activeView) return
606+
// The resize grip and drag handles stay live for read-only members, so
607+
// without this a resize fires a write-gated PATCH and an error toast. Local
608+
// layout still updates — only the persist is suppressed.
609+
if (!activeView || !userPermissions.canEdit) return
594610
updateViewMutation.mutate(
595611
{ viewId: activeView.id, configPatch: patch },
596612
{ onError: (error) => toast.error(getErrorMessage(error, 'Failed to save layout')) }
597613
)
598614
},
599-
[activeView]
615+
[activeView, userPermissions.canEdit]
600616
)
601617

602618
const handleSaveView = () => {

apps/sim/hooks/queries/workspace-files.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { useFileContentSource } from '@/hooks/use-file-content-source'
2727

2828
const logger = createLogger('WorkspaceFilesQuery')
2929

30-
type WorkspaceFileQueryScope = 'active' | 'archived' | 'all'
30+
type WorkspaceFileQueryScope = 'active' | 'archived'
3131

3232
/**
3333
* Query key factories for workspace files

apps/sim/lib/api/contracts/workspace-files.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { inlineFileRefQuerySchema } from '@/lib/api/contracts/primitives'
33
import { shareRecordSchema } from '@/lib/api/contracts/public-shares'
44
import { defineRouteContract } from '@/lib/api/contracts/types'
55

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

814
export const workspaceFilesParamsSchema = z.object({
915
id: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'),

apps/sim/lib/billing/core/billing.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ const {
88
mockComputeDailyRefreshConsumed,
99
mockEnsureUserStatsExists,
1010
mockGetBillingPeriodUsageCost,
11+
mockGetBillingPeriodUsageCostWithSourceSubset,
1112
mockGetHighestPriorityPersonalSubscription,
1213
mockGetHighestPrioritySubscription,
1314
mockResolveBillingInterval,
1415
} = vi.hoisted(() => ({
1516
mockComputeDailyRefreshConsumed: vi.fn(),
1617
mockEnsureUserStatsExists: vi.fn(),
1718
mockGetBillingPeriodUsageCost: vi.fn(),
19+
mockGetBillingPeriodUsageCostWithSourceSubset: vi.fn(),
1820
mockGetHighestPriorityPersonalSubscription: vi.fn(),
1921
mockGetHighestPrioritySubscription: vi.fn(),
2022
mockResolveBillingInterval: vi.fn(),
@@ -35,6 +37,7 @@ vi.mock('@/lib/billing/core/usage', () => ({
3537
vi.mock('@/lib/billing/core/usage-log', () => ({
3638
COPILOT_USAGE_SOURCES: ['copilot'],
3739
getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost,
40+
getBillingPeriodUsageCostWithSourceSubset: mockGetBillingPeriodUsageCostWithSourceSubset,
3841
}))
3942

4043
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
@@ -50,7 +53,7 @@ describe('getPersonalBillingSummary', () => {
5053
mockEnsureUserStatsExists.mockResolvedValue(undefined)
5154
mockResolveBillingInterval.mockReturnValue('year')
5255
mockComputeDailyRefreshConsumed.mockResolvedValue(3)
53-
mockGetBillingPeriodUsageCost.mockResolvedValueOnce(2).mockResolvedValueOnce(1)
56+
mockGetBillingPeriodUsageCostWithSourceSubset.mockResolvedValue({ total: 2, subset: 1 })
5457
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
5558
id: 'personal-sub',
5659
referenceId: 'viewer-a',

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import {
77
resolveBillingInterval,
88
} from '@/lib/billing/core/subscription'
99
import { ensureUserStatsExists } from '@/lib/billing/core/usage'
10-
import { COPILOT_USAGE_SOURCES, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
10+
import {
11+
COPILOT_USAGE_SOURCES,
12+
getBillingPeriodUsageCost,
13+
getBillingPeriodUsageCostWithSourceSubset,
14+
} from '@/lib/billing/core/usage-log'
1115
import {
1216
computeDailyRefreshConsumed,
1317
getOrgMemberRefreshBounds,
@@ -429,15 +433,13 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie
429433
personalSubscription?.periodStart && personalSubscription.periodEnd
430434
? { start: personalSubscription.periodStart, end: personalSubscription.periodEnd }
431435
: defaultBillingPeriod()
432-
const [ledgerUsage, copilotLedgerUsage] = await Promise.all([
433-
getBillingPeriodUsageCost({ type: 'user', id: userId }, billingPeriod, undefined, executor),
434-
getBillingPeriodUsageCost(
436+
const { total: ledgerUsage, subset: copilotLedgerUsage } =
437+
await getBillingPeriodUsageCostWithSourceSubset(
435438
{ type: 'user', id: userId },
436439
billingPeriod,
437440
COPILOT_USAGE_SOURCES,
438441
executor
439-
),
440-
])
442+
)
441443

442444
const hasPersonalUsageSnapshot =
443445
Boolean(personalSubscription) && isPro(plan) && stats.proPeriodCostSnapshotAt !== null

apps/sim/lib/billing/core/usage-log.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,40 @@ export async function getBillingPeriodUsageCost(
207207
return Number.parseFloat(row?.cost ?? '0')
208208
}
209209

210+
/**
211+
* Period total plus the portion attributable to `source`, in a single scan.
212+
*
213+
* Two separate aggregates over the identical row set double the work and, because
214+
* they are separate statements, can observe different snapshots — which makes the
215+
* subset exceeding the total representable. One statement rules that out.
216+
*/
217+
export async function getBillingPeriodUsageCostWithSourceSubset(
218+
billingEntity: BillingEntity,
219+
billingPeriod: { start: Date; end: Date },
220+
source: UsageLogSource[],
221+
executor: DbClient = db
222+
): Promise<{ total: number; subset: number }> {
223+
const [row] = await executor
224+
.select({
225+
total: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
226+
subset: sql<string>`COALESCE(SUM(${usageLog.cost}) FILTER (WHERE ${inArray(usageLog.source, source)}), 0)`,
227+
})
228+
.from(usageLog)
229+
.where(
230+
and(
231+
eq(usageLog.billingEntityType, billingEntity.type),
232+
eq(usageLog.billingEntityId, billingEntity.id),
233+
eq(usageLog.billingPeriodStart, billingPeriod.start),
234+
eq(usageLog.billingPeriodEnd, billingPeriod.end)
235+
)
236+
)
237+
238+
return {
239+
total: Number.parseFloat(row?.total ?? '0'),
240+
subset: Number.parseFloat(row?.subset ?? '0'),
241+
}
242+
}
243+
210244
export async function getBillingPeriodUsageCostByUser(
211245
billingEntity: BillingEntity,
212246
billingPeriod: { start: Date; end: Date },

0 commit comments

Comments
 (0)