Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
21 changes: 13 additions & 8 deletions .agents/rules/web/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,20 @@ Queries, caching, and where data is fetched.
## Query callback lifetimes

Query options can remain cached after their UI unmounts, including for disabled
queries. Callbacks created inside hooks can retain component accessors, Solid
owners, and detached DOM through shared closure environments—even when a callback
only references a locally resolved ID. This does not mean every closure captures
every local variable.
queries: for `gcTime`, and indefinitely on a query other views still observe,
which keeps its last observer's options. Callbacks created inside hooks can
retain component accessors, Solid owners, and detached DOM through shared closure
environments—even when a callback only references a locally resolved ID. This
does not mean every closure captures every local variable.

- Create cached callbacks in module-scope query factories accepting resolved
IDs, flags, and plain data. Resolve reactive inputs inside the `useQuery` /
`useInfiniteQuery` options accessor, e.g. `useQuery(() =>
itemQueryOptions(id()))`, so inputs still update reactively.
- **FE-69** Create cached callbacks in module-scope query factories accepting
resolved IDs, flags, and plain data. Resolve reactive inputs inside the
`useQuery` / `useInfiniteQuery` options accessor, e.g. `useQuery(() =>
itemQueryOptions(id()))`, so inputs still update reactively. Enforced by
ast-grep `ts-/tsx-no-inline-query-callbacks`, CI error, for functions written
in a query hook's options, and in `queryOptions` or client `fetchQuery`-family
options inside a `use*`, `create*`, or component function. Wrapper-hook
arguments (below) are not checked.
- Keep pure selectors and polling policies at module scope, or inside those
factories. Check **all** retained callbacks: `queryFn`, `select`,
`refetchInterval`, pagination, placeholder callbacks, and functions in `meta`.
Expand Down
61 changes: 36 additions & 25 deletions apps/web/src/features/block-database/queries/column-casts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { throwOnErr } from '@core/util/result';
import { storageServiceClient } from '@service-storage/client';
import type { ColumnCast as ServerColumnCast } from '@service-storage/generated/schemas/columnCast';
import { useQuery } from '@tanstack/solid-query';
import { queryOptions, useQuery } from '@tanstack/solid-query';
import type { Accessor } from 'solid-js';
import { match } from 'ts-pattern';
import type {
Expand All @@ -11,37 +11,48 @@ import type {
} from '../../database/core/column-schema';
import { databaseColumnKeys } from './keys';

function columnCastsQueryOptions(
databaseId: string,
tableId: string,
columnId: string,
version: number,
enabled: boolean
) {
return queryOptions({
queryKey: databaseColumnKeys.casts(databaseId, tableId, columnId, version)
.queryKey,
queryFn: () =>
throwOnErr(() =>
storageServiceClient.databases.columnCasts({
id: databaseId,
tableId,
columnId,
})
),
enabled,
select: (casts: ServerColumnCast[]) => ({
version,
casts: casts.map(toMenuCast),
}),
});
}

/** The type menu's dry runs for one table, fetched when a menu opens. */
export function createColumnCasts(params: {
databaseId: string;
tableId: string;
version: Accessor<number>;
}): DatabaseColumnCastsSource {
return (columnId, open) => {
const query = useQuery(() => {
const version = params.version();
return {
queryKey: databaseColumnKeys.casts(
params.databaseId,
params.tableId,
columnId,
version
).queryKey,
queryFn: () =>
throwOnErr(() =>
storageServiceClient.databases.columnCasts({
id: params.databaseId,
tableId: params.tableId,
columnId,
})
),
enabled: open(),
select: (casts: ServerColumnCast[]) => ({
version,
casts: casts.map(toMenuCast),
}),
};
});
const query = useQuery(() =>
columnCastsQueryOptions(
params.databaseId,
params.tableId,
columnId,
params.version(),
open()
)
);
// Gated on status: an unopened menu's query is pending and must not suspend.
return (): DatabaseColumnCasts =>
query.isSuccess
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ const mocks = vi.hoisted(() => ({
invalidateQueries: vi.fn().mockResolvedValue(undefined),
options: undefined as
| undefined
| (() => { enabled: boolean; queryFn: () => Promise<unknown> }),
| (() => {
enabled: boolean;
queryFn: (context: { client: unknown }) => Promise<unknown>;
}),
}));

const runQuery = () =>
mocks.options!().queryFn({
client: { invalidateQueries: mocks.invalidateQueries },
});

vi.mock('@app/lib/analytics/posthog', () => ({
useFeatureFlag: () => () => mocks.flag,
}));
Expand All @@ -30,11 +38,11 @@ vi.mock('@service-storage/client', () => ({
},
}));
vi.mock('@tanstack/solid-query', () => ({
queryOptions: <T>(options: T) => options,
useQuery: (options: typeof mocks.options) => {
mocks.options = options;
return {};
},
useQueryClient: () => ({ invalidateQueries: mocks.invalidateQueries }),
}));

beforeEach(() => {
Expand Down Expand Up @@ -72,7 +80,7 @@ describe('starter onboarding', () => {
});

it('opens the newly seeded board and refreshes the database list', async () => {
await mocks.options!().queryFn();
await runQuery();
const selection = createUserScopedStorage(
'database-view-selection:example'
).read(mocks.userId!);
Expand All @@ -86,7 +94,7 @@ describe('starter onboarding', () => {
it('does not replace an existing browser view preference', async () => {
const storage = createUserScopedStorage('database-view-selection:example');
storage.write(mocks.userId!, 'existing preference');
await mocks.options!().queryFn();
await runQuery();
expect(storage.read(mocks.userId!)).toBe('existing preference');
});

Expand All @@ -97,17 +105,17 @@ describe('starter onboarding', () => {
tableId: null,
viewId: null,
});
await mocks.options!().queryFn();
await runQuery();
expect(localStorage.length).toBe(0);
expect(mocks.invalidateQueries).not.toHaveBeenCalled();
});

it('keeps failed provisioning retryable without marking it complete', async () => {
mocks.ensureStarter.mockRejectedValueOnce(new Error('offline'));
await expect(mocks.options!().queryFn()).rejects.toThrow('offline');
await expect(runQuery()).rejects.toThrow('offline');
expect(localStorage.length).toBe(0);
expect(mocks.invalidateQueries).not.toHaveBeenCalled();
await mocks.options!().queryFn();
await runQuery();
expect(mocks.ensureStarter).toHaveBeenCalledTimes(2);
});
});
44 changes: 26 additions & 18 deletions apps/web/src/features/block-database/queries/starter-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,19 @@ import { createUserScopedStorage } from '@core/util/userScopedStorage';
import { useDatabasesQuery } from '@queries/storage/databases';
import { databasesKeys } from '@queries/storage/keys';
import { storageServiceClient } from '@service-storage/client';
import { useQuery, useQueryClient } from '@tanstack/solid-query';
import { queryOptions, useQuery } from '@tanstack/solid-query';
import type { DatabaseViewSelection } from '../../database/core/view-selection';

/** Mounted inside authenticated app chrome. Provisioning never suspends the app. */
export function useStarterDatabase() {
const userId = useUserId();
const flag = useFeatureFlag(enableDatabases);
const databases = useDatabasesQuery();
const client = useQueryClient();
return useQuery(() => ({
queryKey: databasesKeys.starter(userId() ?? '').queryKey,
enabled:
!!userId() &&
!flag().loading &&
flag().enabled &&
databases.isSuccess &&
databases.data?.length === 0,
function starterDatabaseQueryOptions(
ownerId: string | undefined,
enabled: boolean
) {
return queryOptions({
queryKey: databasesKeys.starter(ownerId ?? '').queryKey,
enabled: !!ownerId && enabled,
staleTime: Infinity,
gcTime: Infinity,
queryFn: async () => {
const ownerId = userId();
queryFn: async ({ client }) => {
const result = await throwOnErr(() =>
storageServiceClient.databases.ensureStarter()
);
Expand Down Expand Up @@ -58,5 +50,21 @@ export function useStarterDatabase() {
});
return result;
},
}));
});
}

/** Mounted inside authenticated app chrome. Provisioning never suspends the app. */
export function useStarterDatabase() {
const userId = useUserId();
const flag = useFeatureFlag(enableDatabases);
const databases = useDatabasesQuery();
return useQuery(() =>
starterDatabaseQueryOptions(
userId(),
!flag().loading &&
flag().enabled &&
databases.isSuccess &&
databases.data?.length === 0
)
);
}
35 changes: 18 additions & 17 deletions apps/web/src/features/block-database/queries/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { CardPosition } from '@service-storage/generated/schemas/cardPositi
import type { DatabaseView } from '@service-storage/generated/schemas/databaseView';
import type { NewView } from '@service-storage/generated/schemas/newView';
import type { ViewQuery } from '@service-storage/generated/schemas/viewQuery';
import { useQuery } from '@tanstack/solid-query';
import { queryOptions, useQuery } from '@tanstack/solid-query';
import { errAsync, okAsync, ResultAsync } from 'neverthrow';
import type { Accessor } from 'solid-js';
import { v7 as uuidv7 } from 'uuid';
Expand Down Expand Up @@ -299,27 +299,28 @@ export function reorderDatabaseViews(
});
}

function cardPositionsQueryOptions(databaseId: string, id: string | undefined) {
return queryOptions({
queryKey: databaseViewKeys.positions(databaseId, id ?? '').queryKey,
queryFn: async () => {
const { positions } = await throwOnErr(() =>
storageServiceClient.databases.viewPositions({
id: databaseId,
viewId: id ?? '',
})
);
return positions;
},
enabled: !!id,
});
}

/** Where a board's cards sit, read again when its table changes. */
export function useCardPositions(
databaseId: string,
viewId: Accessor<string | undefined>
) {
return useQuery(() => {
const id = viewId();
return {
queryKey: databaseViewKeys.positions(databaseId, id ?? '').queryKey,
queryFn: async () => {
const { positions } = await throwOnErr(() =>
storageServiceClient.databases.viewPositions({
id: databaseId,
viewId: id ?? '',
})
);
return positions;
},
enabled: !!id,
};
});
return useQuery(() => cardPositionsQueryOptions(databaseId, viewId()));
}

export function setCardPositions(
Expand Down
20 changes: 12 additions & 8 deletions apps/web/src/features/block-database/queries/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,21 @@ import { queryClient } from '@queries/client';
import { storageServiceClient } from '@service-storage/client';
import type { CreatedDatabaseWebhookResponse } from '@service-storage/generated/schemas/createdDatabaseWebhookResponse';
import type { DatabaseWebhookResponse } from '@service-storage/generated/schemas/databaseWebhookResponse';
import { useQuery } from '@tanstack/solid-query';
import { queryOptions, useQuery } from '@tanstack/solid-query';
import type { ResultAsync } from 'neverthrow';
import type { Accessor } from 'solid-js';
import { databaseWebhookKeys } from './keys';

function databaseWebhooksQueryOptions(databaseId: string) {
return queryOptions({
queryKey: databaseWebhookKeys.list(databaseId).queryKey,
queryFn: () =>
throwOnErr(() =>
storageServiceClient.databases.listWebhooks({ id: databaseId })
),
});
}

/** A table's webhooks; `undefined` until they are read. */
export function useTableWebhooks(
databaseId: Accessor<string>,
Expand All @@ -18,13 +28,7 @@ export function useTableWebhooks(
webhooks: Accessor<DatabaseWebhookResponse[] | undefined>;
failed: Accessor<boolean>;
} {
const query = useQuery(() => ({
queryKey: databaseWebhookKeys.list(databaseId()).queryKey,
queryFn: () =>
throwOnErr(() =>
storageServiceClient.databases.listWebhooks({ id: databaseId() })
),
}));
const query = useQuery(() => databaseWebhooksQueryOptions(databaseId()));
return {
webhooks: () =>
query.isSuccess
Expand Down
20 changes: 12 additions & 8 deletions apps/web/src/features/block-pr/queries/pr-links-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
import { agentHarnessServiceClient } from '@service-agent-harness/client';
import type { MessageParent } from '@service-agent-harness/generated/schemas';
import { storageServiceClient } from '@service-storage/client';
import { useQueries } from '@tanstack/solid-query';
import { queryOptions, useQueries } from '@tanstack/solid-query';
import { type Accessor, createMemo } from 'solid-js';
import {
buildPrLinks,
Expand Down Expand Up @@ -252,6 +252,16 @@ const chunk = <T>(items: readonly T[], size: number): T[][] =>
items.slice(index * size, (index + 1) * size)
);

function prLinksQueryOptions(pullRequests: readonly PrLinksTarget[]) {
return queryOptions({
queryKey: agentSessionPullRequestKeys.linksForPullRequests(
pullRequests.map((pullRequest) => pullRequest.url)
).queryKey,
queryFn: () => fetchPrLinks(pullRequests),
staleTime: LINKS_STALE_TIME,
});
}

/**
* Links for each pull request, fetched one page-sized chunk at a time so a
* list that loads another page reuses the earlier chunks' cache entries.
Expand All @@ -261,13 +271,7 @@ export function usePrLinksQuery(
) {
const chunks = createMemo(() => chunk(pullRequests(), LINKS_CHUNK_SIZE));
const queries = useQueries(() => ({
queries: chunks().map((pullRequests) => ({
queryKey: agentSessionPullRequestKeys.linksForPullRequests(
pullRequests.map((pullRequest) => pullRequest.url)
).queryKey,
queryFn: () => fetchPrLinks(pullRequests),
staleTime: LINKS_STALE_TIME,
})),
queries: chunks().map(prLinksQueryOptions),
}));

const page = createMemo<PrLinksPage>(() => {
Expand Down
Loading
Loading