From 307c6e3cb11739188e0951de9b557c3624e349e4 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Mon, 14 Sep 2026 14:48:56 +0530 Subject: [PATCH 01/16] feat(pages): paginate and search page.list instead of returning the whole org page.list selected every page in the organization with no LIMIT and then built a presigned URL per row, so the query and the response both grew linearly with org size. It now takes search/limit/cursor and returns { items, nextCursor }. Pagination is a keyset on (updatedAt, id) rather than an offset: the list is ordered by updatedAt, so a republish mid-sweep reshuffles it and a row offset would skip rows. The cursor carries the timestamp as Postgres' own text rather than a JS Date, because updated_at is microsecond precision from defaultNow() while a Date is milliseconds -- a truncated cursor silently drops every row inside the truncated microsecond. Ordering stays updatedAt DESC so pages_organization_id_updated_at_idx still serves it and no migration is needed. Search is ILIKE over title and slug with LIKE metacharacters escaped, so searching for "100%" does not match everything. Callers: - cli pages list gains --search/-q, --limit and --cursor. With neither --limit nor --cursor it drains every page and still emits a bare array, so existing --json and --quiet scripts are unaffected; passing either opts into a single batch and the { items, nextCursor } envelope. - cli pages comments list drains the cursor. It sweeps every page, so a default cap would have silently hidden comments. - mcp pages_list forwards search/limit/cursor and tells the model about nextCursor. - desktop and mobile drain in the background; their tab counts and scope filters are computed over the whole set, so a cap would have made them wrong. cli-framework's extractIds unwraps a paginated envelope so --quiet keeps printing ids instead of raw JSON. --- .../pages/components/PagesView/PagesView.tsx | 55 +++++++++++--- .../pages/hooks/usePages/usePages.ts | 18 ++++- bun.lock | 1 + packages/cli-framework/src/output.test.ts | 43 ++++++++++- packages/cli-framework/src/output.ts | 12 ++++ .../commands/pages/comments/list/command.ts | 9 ++- .../cli/src/commands/pages/list/command.ts | 70 ++++++++++++++++-- .../cli/src/commands/pages/pageList.test.ts | 46 ++++++++++++ packages/cli/src/commands/pages/pageList.ts | 72 +++++++++++++++++++ packages/mcp/src/tools/pages/list.ts | 38 ++++++++-- packages/trpc/src/router/page/page.ts | 67 +++++++++++++---- packages/trpc/src/router/page/schema.test.ts | 41 ++++++++++- packages/trpc/src/router/page/schema.ts | 22 +++++- 13 files changed, 452 insertions(+), 42 deletions(-) create mode 100644 packages/cli/src/commands/pages/pageList.test.ts create mode 100644 packages/cli/src/commands/pages/pageList.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx index e9a1c40fb28..e53c846307c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx @@ -22,6 +22,8 @@ import { PagesGrid } from "../PagesGrid"; import { useCreatePageWithAgent } from "./hooks/useCreatePageWithAgent"; import { usePageFavorites } from "./hooks/usePageFavorites"; +const PAGES_QUERY = { limit: 200 } as const; + const TABS: Array<{ value: PageScope }> = [ { value: "all" }, { value: "pinned" }, @@ -46,21 +48,45 @@ export function PagesView({ const { creatingWithAgent, handleCreateWithAgent } = useCreatePageWithAgent(); const { data: session } = authClient.useSession(); const utils = cloudTrpc.useUtils(); - const pages = cloudTrpc.page.list.useQuery({}); + const pages = cloudTrpc.page.list.useInfiniteQuery(PAGES_QUERY, { + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }); + const { + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + fetchNextPage, + } = pages; + useEffect(() => { + if (hasNextPage && !isFetchingNextPage && !isFetchNextPageError) { + void fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]); + const deletePage = cloudTrpc.page.delete.useMutation({ onMutate: async ({ id }) => { - await utils.page.list.cancel({}); - const previous = utils.page.list.getData({}); - utils.page.list.setData({}, (old) => - old?.filter((entry) => entry.id !== id), + await utils.page.list.cancel(PAGES_QUERY); + const previous = utils.page.list.getInfiniteData(PAGES_QUERY); + utils.page.list.setInfiniteData(PAGES_QUERY, (old) => + old + ? { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.filter((entry) => entry.id !== id), + })), + } + : old, ); return { previous }; }, onError: (_error, _variables, context) => { - if (context?.previous) utils.page.list.setData({}, context.previous); + if (context?.previous) { + utils.page.list.setInfiniteData(PAGES_QUERY, context.previous); + } }, onSettled: () => { - void utils.page.list.invalidate({}); + void utils.page.list.invalidate(PAGES_QUERY); }, }); const { favoritePageIdSet, toggleFavorite } = usePageFavorites(); @@ -73,7 +99,10 @@ export function PagesView({ mine: t({ message: "Just me" }), }; - const all = useMemo(() => pages.data ?? [], [pages.data]); + const all = useMemo( + () => pages.data?.pages.flatMap((page) => page.items) ?? [], + [pages.data], + ); const counts = useMemo( () => ({ @@ -88,11 +117,15 @@ export function PagesView({ ); const tabs = useMemo( - () => TABS.filter((tab) => tab.value !== "pinned" || counts.pinned > 0), - [counts.pinned], + () => + TABS.filter( + (tab) => + tab.value !== "pinned" || counts.pinned > 0 || scope === "pinned", + ), + [counts.pinned, scope], ); - const pinnedEmpty = scope === "pinned" && counts.pinned === 0; + const pinnedEmpty = !hasNextPage && scope === "pinned" && counts.pinned === 0; const activeScope = pinnedEmpty ? "all" : scope; useEffect(() => { diff --git a/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts b/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts index 3967dc03ae9..c53b9e2f383 100644 --- a/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts +++ b/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts @@ -3,11 +3,13 @@ import { type UseQueryResult, useQuery } from "@tanstack/react-query"; import { useSession } from "@/lib/auth/client"; import { apiClient } from "@/lib/trpc/client"; -export type OrgPage = RouterOutputs["page"]["list"][number]; +export type OrgPage = RouterOutputs["page"]["list"]["items"][number]; export type PulledPage = RouterOutputs["page"]["pull"]; export const NO_PAGES: OrgPage[] = []; +const PAGES_PER_REQUEST = 200; + export function usePagesQuery(): UseQueryResult { const { data: session } = useSession(); const organizationId = session?.session?.activeOrganizationId ?? null; @@ -15,7 +17,19 @@ export function usePagesQuery(): UseQueryResult { return useQuery({ queryKey: ["cloud", "page", "list", organizationId], enabled: organizationId !== null, - queryFn: () => apiClient.page.list.query({}), + queryFn: async () => { + const items: OrgPage[] = []; + let cursor: { updatedAt: string; id: string } | undefined; + do { + const result = await apiClient.page.list.query({ + limit: PAGES_PER_REQUEST, + ...(cursor ? { cursor } : {}), + }); + items.push(...result.items); + cursor = result.nextCursor ?? undefined; + } while (cursor); + return items; + }, staleTime: 30_000, }); } diff --git a/bun.lock b/bun.lock index a9694d30098..f94e763d7a2 100644 --- a/bun.lock +++ b/bun.lock @@ -932,6 +932,7 @@ "@superset/typescript": "workspace:*", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.0", + "@trpc/client": "11.16.0", "@types/react": "19.2.14", "bun-types": "1.3.14", "react": "19.2.3", diff --git a/packages/cli-framework/src/output.test.ts b/packages/cli-framework/src/output.test.ts index b0c676bfbfc..1ff6a05a5f6 100644 --- a/packages/cli-framework/src/output.test.ts +++ b/packages/cli-framework/src/output.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { table } from "./output"; +import { formatOutput, table } from "./output"; const URL = "http://localhost:3000/page/schema-history-superset-7njjhq"; const ROWS = [{ title: "Schema history", url: URL }]; @@ -93,3 +93,44 @@ describe("table", () => { expect(out).not.toContain("\x1b]8;;"); }); }); + +describe("formatOutput --quiet", () => { + test("lists the ids inside a paginated envelope", () => { + const out = formatOutput( + { data: { items: [{ id: "a" }, { id: "b" }], nextCursor: "tok" } }, + undefined, + { json: false, quiet: true }, + ); + expect(out).toBe("a\nb"); + }); + + test("still lists the ids of a bare array", () => { + const out = formatOutput([{ id: "a" }, { id: "b" }], undefined, { + json: false, + quiet: true, + }); + expect(out).toBe("a\nb"); + }); + + test("leaves an object that merely has items alone", () => { + const out = formatOutput({ items: [{ id: "a" }] }, undefined, { + json: false, + quiet: true, + }); + expect(out).toBe(JSON.stringify({ items: [{ id: "a" }] })); + }); +}); + +describe("formatOutput --json", () => { + test("hands the cursor to the caller alongside the rows", () => { + const out = formatOutput( + { data: { items: [{ id: "a" }], nextCursor: "tok" } }, + undefined, + { json: true, quiet: false }, + ); + expect(JSON.parse(out)).toEqual({ + items: [{ id: "a" }], + nextCursor: "tok", + }); + }); +}); diff --git a/packages/cli-framework/src/output.ts b/packages/cli-framework/src/output.ts index 24bc19c291c..37d4e0a5734 100644 --- a/packages/cli-framework/src/output.ts +++ b/packages/cli-framework/src/output.ts @@ -45,6 +45,8 @@ function isResultWithMessage(result: unknown): result is { message: string } { } function extractIds(data: unknown): string { + if (isPaginatedEnvelope(data)) return extractIds(data.items); + if (Array.isArray(data)) { return data .map((item) => { @@ -63,6 +65,16 @@ function extractIds(data: unknown): string { return JSON.stringify(data); } +function isPaginatedEnvelope(data: unknown): data is { items: unknown[] } { + return ( + typeof data === "object" && + data !== null && + "items" in data && + "nextCursor" in data && + Array.isArray((data as { items: unknown }).items) + ); +} + const URL_CELL = /^https?:\/\/\S+$/; const supportsHyperlinks = () => diff --git a/packages/cli/src/commands/pages/comments/list/command.ts b/packages/cli/src/commands/pages/comments/list/command.ts index 46edda51b2f..83a2ba07c25 100644 --- a/packages/cli/src/commands/pages/comments/list/command.ts +++ b/packages/cli/src/commands/pages/comments/list/command.ts @@ -1,6 +1,7 @@ import { boolean, CLIError, string } from "@superset/cli-framework"; import { command } from "../../../../lib/command"; import { resolvePageId } from "../../pageId"; +import { fetchAllPages } from "../../pageList"; import { agentSessionId } from "../agentSession"; interface ThreadComment { @@ -46,9 +47,11 @@ export default command({ ...activatedOnly, })) as unknown as Thread[]; } else { - const pages = await ctx.api.page.list.query( - options.workspace ? { workspaceId: options.workspace } : undefined, - ); + const pages = await fetchAllPages<{ + id: string; + title: string; + slug: string; + }>(ctx, options.workspace ? { workspaceId: options.workspace } : {}); const perPage = await mapWithConcurrency(pages, 8, async (page) => { const rows = (await ctx.api.pageComment.list.query({ pageId: page.id, diff --git a/packages/cli/src/commands/pages/list/command.ts b/packages/cli/src/commands/pages/list/command.ts index 5c777e50038..016d18b140e 100644 --- a/packages/cli/src/commands/pages/list/command.ts +++ b/packages/cli/src/commands/pages/list/command.ts @@ -1,15 +1,42 @@ -import { string, table } from "@superset/cli-framework"; +import { CLIError, number, string, table } from "@superset/cli-framework"; +import { PAGE_LIST_MAX_LIMIT } from "@superset/trpc/page-schema"; import { command } from "../../../lib/command"; +import { + decodeCursor, + encodeCursor, + fetchAllPages, + fetchPageList, +} from "../pageList"; import { resolveWorkspaceId } from "../workspaceRef"; +type PageRow = Record; + export default command({ description: "List pages in the organization", options: { workspace: string().desc( "Only pages published from this workspace, by name or id (defaults to $SUPERSET_WORKSPACE_ID)", ), + search: string() + .alias("q") + .desc("Only pages whose title or slug contains this text"), + limit: number().desc( + `Return one batch of this many pages (1-${PAGE_LIST_MAX_LIMIT}) plus a cursor, instead of every page`, + ), + cursor: string().desc("Continue from a previous run's nextCursor"), }, run: async ({ ctx, options }) => { + if (options.limit !== undefined) { + if (!Number.isInteger(options.limit)) { + throw new CLIError(`--limit must be a whole number`); + } + if (options.limit < 1 || options.limit > PAGE_LIST_MAX_LIMIT) { + throw new CLIError( + `--limit must be between 1 and ${PAGE_LIST_MAX_LIMIT}`, + ); + } + } + const workspace = options.workspace ?? process.env.SUPERSET_WORKSPACE_ID; const workspaceId = workspace ? await resolveWorkspaceId({ @@ -19,13 +46,39 @@ export default command({ api: ctx.api, }) : undefined; - return await ctx.api.page.list.query( - workspaceId ? { workspaceId } : undefined, + + const query = { + ...(workspaceId ? { workspaceId } : {}), + ...(options.search ? { search: options.search } : {}), + ...(options.limit !== undefined ? { limit: options.limit } : {}), + }; + + if (options.limit === undefined && options.cursor === undefined) { + return { data: await fetchAllPages(ctx, query) }; + } + + const result = await fetchPageList( + ctx, + query, + options.cursor ? decodeCursor(options.cursor) : undefined, ); + return { + data: { + items: result.items, + nextCursor: result.nextCursor ? encodeCursor(result.nextCursor) : null, + }, + }; }, - display: (data) => - table( - (data as Record[]).map((row) => ({ + display: (data) => { + const items = Array.isArray(data) + ? (data as PageRow[]) + : ((data as { items: PageRow[] }).items ?? []); + const nextCursor = Array.isArray(data) + ? null + : ((data as { nextCursor: string | null }).nextCursor ?? null); + + const rendered = table( + items.map((row) => ({ title: row.title, version: row.latestVersion, visibility: row.visibility, @@ -35,5 +88,8 @@ export default command({ ["title", "version", "visibility", "url", "id"], ["TITLE", "V", "VISIBILITY", "URL", "ID"], [30, 4, 10, 50, 36], - ), + ); + if (!nextCursor) return rendered; + return `${rendered}\n\nMore pages available — re-run with --cursor ${nextCursor}, or drop --limit to fetch every page.`; + }, }); diff --git a/packages/cli/src/commands/pages/pageList.test.ts b/packages/cli/src/commands/pages/pageList.test.ts new file mode 100644 index 00000000000..bf0b092cbd8 --- /dev/null +++ b/packages/cli/src/commands/pages/pageList.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { CLIError } from "@superset/cli-framework"; +import { decodeCursor, encodeCursor } from "./pageList"; + +const PAGE = "00000000-0000-4000-8000-000000000002"; +const UPDATED_AT = "2026-09-14 10:00:00.123456+00"; + +describe("page list cursor", () => { + test("survives a round trip through the command line", () => { + const encoded = encodeCursor({ updatedAt: UPDATED_AT, id: PAGE }); + expect(decodeCursor(encoded)).toEqual({ + updatedAt: UPDATED_AT, + id: PAGE, + }); + }); + + test("keeps the sub-millisecond digits Postgres reported", () => { + const decoded = decodeCursor( + encodeCursor({ updatedAt: UPDATED_AT, id: PAGE }), + ); + expect(decoded.updatedAt).toContain(".123456"); + }); + + test("encodes without characters a shell would mangle", () => { + const encoded = encodeCursor({ updatedAt: UPDATED_AT, id: PAGE }); + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + test("reports a typo'd cursor as a CLI error, not a parse crash", () => { + expect(() => decodeCursor("not-a-cursor")).toThrow(CLIError); + }); + + test("rejects a well-formed token missing the id", () => { + const encoded = Buffer.from( + JSON.stringify({ updatedAt: UPDATED_AT }), + ).toString("base64url"); + expect(() => decodeCursor(encoded)).toThrow(CLIError); + }); + + test("rejects a token whose timestamp is not a string", () => { + const encoded = Buffer.from( + JSON.stringify({ updatedAt: 12345, id: PAGE }), + ).toString("base64url"); + expect(() => decodeCursor(encoded)).toThrow(CLIError); + }); +}); diff --git a/packages/cli/src/commands/pages/pageList.ts b/packages/cli/src/commands/pages/pageList.ts new file mode 100644 index 00000000000..e89feb2109f --- /dev/null +++ b/packages/cli/src/commands/pages/pageList.ts @@ -0,0 +1,72 @@ +import { CLIError } from "@superset/cli-framework"; +import { PAGE_LIST_MAX_LIMIT } from "@superset/trpc/page-schema"; +import type { CliContext } from "../../lib/command"; + +export interface PageListCursor { + updatedAt: string; + id: string; +} + +export interface PageListQuery { + workspaceId?: string; + search?: string; + limit?: number; +} + +export interface PageListPage { + items: TPage[]; + nextCursor: PageListCursor | null; +} + +export async function fetchPageList( + ctx: CliContext, + query: PageListQuery, + cursor?: PageListCursor, +): Promise> { + return (await ctx.api.page.list.query({ + ...query, + ...(cursor ? { cursor } : {}), + })) as unknown as PageListPage; +} + +export async function fetchAllPages( + ctx: CliContext, + query: PageListQuery = {}, +): Promise { + const batched = { limit: PAGE_LIST_MAX_LIMIT, ...query }; + const items: TPage[] = []; + let cursor: PageListCursor | undefined; + do { + const result = await fetchPageList(ctx, batched, cursor); + items.push(...result.items); + cursor = result.nextCursor ?? undefined; + } while (cursor); + return items; +} + +export function encodeCursor(cursor: PageListCursor): string { + return Buffer.from( + JSON.stringify({ updatedAt: cursor.updatedAt, id: cursor.id }), + ).toString("base64url"); +} + +export function decodeCursor(value: string): PageListCursor { + const invalid = () => + new CLIError( + "Could not read --cursor", + "Pass the nextCursor from a previous --json run, or drop the flag to start over", + ); + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + } catch { + throw invalid(); + } + + const cursor = parsed as Partial | null; + if (typeof cursor?.id !== "string" || typeof cursor.updatedAt !== "string") { + throw invalid(); + } + return { updatedAt: cursor.updatedAt, id: cursor.id }; +} diff --git a/packages/mcp/src/tools/pages/list.ts b/packages/mcp/src/tools/pages/list.ts index 46782efd82c..cef5e62a05d 100644 --- a/packages/mcp/src/tools/pages/list.ts +++ b/packages/mcp/src/tools/pages/list.ts @@ -1,4 +1,8 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + PAGE_LIST_DEFAULT_LIMIT, + PAGE_LIST_MAX_LIMIT, +} from "@superset/trpc/page-schema"; import { z } from "zod"; import { createMcpCaller } from "../../caller"; import { defineTool } from "../../define-tool"; @@ -8,7 +12,7 @@ export function register(server: McpServer): void { name: "pages_list", annotations: { readOnlyHint: true }, description: - "List published pages in the active organization, newest first. A page is one self-contained HTML file published to a shareable URL. Returns only pages the caller may read: everything shared with the organization, plus the caller's own private ones. Use this to find a page's id or slug before calling any other pages_* tool.", + "List published pages in the active organization, newest first. A page is one self-contained HTML file published to a shareable URL. Returns only pages the caller may read: everything shared with the organization, plus the caller's own private ones. Use this to find a page's id or slug before calling any other pages_* tool. Results are paginated: pass `search` to narrow by title or slug, and when `nextCursor` comes back non-null pass it as `cursor` to get the next batch.", inputSchema: { workspaceId: z .string() @@ -17,12 +21,38 @@ export function register(server: McpServer): void { .describe( "Only pages published from this workspace. Omit for every page in the organization.", ), + search: z + .string() + .min(1) + .max(200) + .nullish() + .describe( + "Only pages whose title or slug contains this text, case-insensitive.", + ), + limit: z + .number() + .int() + .min(1) + .max(PAGE_LIST_MAX_LIMIT) + .nullish() + .describe( + `How many pages to return, 1-${PAGE_LIST_MAX_LIMIT} (default ${PAGE_LIST_DEFAULT_LIMIT}).`, + ), + cursor: z + .object({ updatedAt: z.string(), id: z.string().uuid() }) + .nullish() + .describe( + "The `nextCursor` object from a previous call, passed back unchanged, to fetch the next batch. Omit for the first batch.", + ), }, handler: async (input, ctx) => { const caller = createMcpCaller(ctx); - return caller.page.list( - input?.workspaceId ? { workspaceId: input.workspaceId } : undefined, - ); + return caller.page.list({ + ...(input?.workspaceId ? { workspaceId: input.workspaceId } : {}), + ...(input?.search ? { search: input.search } : {}), + ...(input?.limit ? { limit: input.limit } : {}), + ...(input?.cursor ? { cursor: input.cursor } : {}), + }); }, }); } diff --git a/packages/trpc/src/router/page/page.ts b/packages/trpc/src/router/page/page.ts index 25cf13659d0..ded6dcd8399 100644 --- a/packages/trpc/src/router/page/page.ts +++ b/packages/trpc/src/router/page/page.ts @@ -19,7 +19,17 @@ import { pageViewUrl, } from "@superset/shared/usercontent"; import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; -import { and, desc, eq, inArray, or, type SQL, sql } from "drizzle-orm"; +import { + and, + desc, + eq, + ilike, + inArray, + lt, + or, + type SQL, + sql, +} from "drizzle-orm"; import { z } from "zod"; import { env } from "../../env"; import { deleteObjects, presignedGetUrl } from "../../lib/r2"; @@ -35,6 +45,7 @@ import { createPageSchema, deletePageSchema, listPagesSchema, + PAGE_LIST_DEFAULT_LIMIT, pageFields, pageRefSchema, publishPageSchema, @@ -53,6 +64,10 @@ import { enqueuePageThumbnail } from "./thumbnail"; import { watchState } from "./watch"; import { assertWorkspaceAccess } from "./workspace-access"; +function escapeLikePattern(term: string): string { + return term.replace(/[\\%_]/g, (char) => `\\${char}`); +} + function visibilityFilter(userId: string) { return or( eq(pages.visibility, "org"), @@ -235,6 +250,7 @@ export const pageRouter = { .query(async ({ ctx, input }) => { const organizationId = await requireActiveOrgMembership(ctx); const userId = ctx.session.user.id; + const limit = input?.limit ?? PAGE_LIST_DEFAULT_LIMIT; if (input?.workspaceId) { await assertWorkspaceAccess({ @@ -267,6 +283,7 @@ export const pageRouter = { sharedVersion: pages.sharedVersion, createdAt: pages.createdAt, updatedAt: pages.updatedAt, + updatedAtCursor: sql`${pages.updatedAt}::text`, createdByUserId: pages.createdByUserId, ownerName: users.name, latestVersion: latest.version, @@ -278,27 +295,51 @@ export const pageRouter = { .leftJoin(users, eq(users.id, pages.createdByUserId)) .leftJoinLateral(latest, sql`true`); + const filters: (SQL | undefined)[] = [ + eq(pages.organizationId, organizationId), + visibilityFilter(userId), + ]; + + if (input?.search) { + const term = `%${escapeLikePattern(input.search)}%`; + filters.push(or(ilike(pages.title, term), ilike(pages.slug, term))); + } + + if (input?.cursor) { + const at = sql`${input.cursor.updatedAt}::timestamptz`; + filters.push( + or( + lt(pages.updatedAt, at), + and(eq(pages.updatedAt, at), lt(pages.id, input.cursor.id)), + ), + ); + } + const scoped = input?.workspaceId ? base .innerJoin(workspacePages, eq(workspacePages.pageId, pages.id)) .where( and( - eq(pages.organizationId, organizationId), + ...filters, eq(workspacePages.workspaceId, input.workspaceId), - visibilityFilter(userId), ), ) - : base.where( - and( - eq(pages.organizationId, organizationId), - visibilityFilter(userId), - ), - ); + : base.where(and(...filters)); + + const rows = await scoped + .orderBy(desc(pages.updatedAt), desc(pages.id)) + .limit(limit + 1); + + const pageRows = rows.slice(0, limit); + const last = pageRows.at(-1); + const nextCursor = + rows.length > limit && last + ? { updatedAt: last.updatedAtCursor, id: last.id } + : null; - const rows = await scoped.orderBy(desc(pages.updatedAt)); const baseUrl = env.USERCONTENT_URL; - return await Promise.all( - rows.map(async (row) => { + const items = await Promise.all( + pageRows.map(async ({ updatedAtCursor: _cursor, ...row }) => { const served = servedVersion(row.sharedVersion, row.latestVersion); const ticket = await mintPageTicket(row); // Version-bound, so it turns daily instead of hourly — the capture @@ -325,6 +366,8 @@ export const pageRouter = { }; }), ); + + return { items, nextCursor }; }), get: protectedProcedure.input(pageRefSchema).query(async ({ ctx, input }) => { diff --git a/packages/trpc/src/router/page/schema.test.ts b/packages/trpc/src/router/page/schema.test.ts index c71a9c2eb25..4117b49c816 100644 --- a/packages/trpc/src/router/page/schema.test.ts +++ b/packages/trpc/src/router/page/schema.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { publishPageSchema } from "./schema"; +import { + listPagesSchema, + PAGE_LIST_DEFAULT_LIMIT, + PAGE_LIST_MAX_LIMIT, + publishPageSchema, +} from "./schema"; const WORKSPACE = "00000000-0000-4000-8000-000000000001"; const PAGE = "00000000-0000-4000-8000-000000000002"; @@ -81,3 +86,37 @@ describe("publishPageSchema", () => { ).toBe(true); }); }); + +describe("listPagesSchema", () => { + test("defaults the limit when an input object omits it", () => { + const result = listPagesSchema.parse({}); + expect(result?.limit).toBe(PAGE_LIST_DEFAULT_LIMIT); + }); + + test("still accepts the no-argument call the CLI and MCP tools make", () => { + expect(listPagesSchema.parse(undefined)).toBeUndefined(); + }); + + test("refuses a limit past the ceiling", () => { + expect( + listPagesSchema.safeParse({ limit: PAGE_LIST_MAX_LIMIT + 1 }).success, + ).toBe(false); + }); + + test("refuses an empty search rather than matching everything", () => { + expect(listPagesSchema.safeParse({ search: "" }).success).toBe(false); + }); + + test("keeps the cursor timestamp as Postgres reported it, microseconds and all", () => { + const result = listPagesSchema.parse({ + cursor: { updatedAt: "2026-09-14 10:00:00.123456+00", id: PAGE }, + }); + expect(result?.cursor?.updatedAt).toBe("2026-09-14 10:00:00.123456+00"); + }); + + test("refuses half a cursor", () => { + expect(listPagesSchema.safeParse({ cursor: { id: PAGE } }).success).toBe( + false, + ); + }); +}); diff --git a/packages/trpc/src/router/page/schema.ts b/packages/trpc/src/router/page/schema.ts index bc0e5ace4fe..91e55633422 100644 --- a/packages/trpc/src/router/page/schema.ts +++ b/packages/trpc/src/router/page/schema.ts @@ -105,10 +105,30 @@ export const createPageSchema = z export type CreatePageInput = z.infer; +export const PAGE_LIST_DEFAULT_LIMIT = 50; +export const PAGE_LIST_MAX_LIMIT = 200; + +export const pageListCursorSchema = z.object({ + updatedAt: z.string().min(1).max(64), + id: pageFields.id, +}); + export const listPagesSchema = z - .object({ workspaceId: pageFields.workspaceId.optional() }) + .object({ + workspaceId: pageFields.workspaceId.optional(), + search: z.string().min(1).max(200).optional(), + cursor: pageListCursorSchema.optional(), + limit: z + .number() + .int() + .min(1) + .max(PAGE_LIST_MAX_LIMIT) + .default(PAGE_LIST_DEFAULT_LIMIT), + }) .optional(); +export type PageListCursor = z.infer; + const pageRefFieldsSchema = z.object({ id: pageFields.id.optional(), slug: pageFields.slug.optional(), From b129b84e5d104c8f6db9769de945277d08a0e1bf Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Mon, 14 Sep 2026 15:17:45 +0530 Subject: [PATCH 02/16] fix(pages): stop a failed drain batch from blanking the page grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An infinite query has one error slot. `InfiniteQueryObserver` derives `isFetchNextPageError` from the shared `isError`, so a failed background `fetchNextPage` sets `query.error` while `data` still holds every batch that succeeded. `PagesGrid` renders `error` as an early return, so one dropped packet mid-sweep replaced a populated grid with an error string — and the `isFetchNextPageError` gate meant the drain never retried, so it stayed that way. The error now only takes the screen when nothing loaded. `hasNextPage(options, undefined)` returns false, so `pinnedEmpty` was true on cold mount and the effect kicked the user off the Pinned tab before the first batch landed. Gated on `pages.data` existing. Also: - Mobile's drain ignored React Query's `signal`, so a cancelled query still ran every remaining request to completion. Forwarded to each call. - `--limit` hand-rolled the integer and range checks that `number().int().min() .max()` already enforces in `parser.ts` before the command body runs. Using the builder drops the duplicate path and puts the constraint in `--help`. - `cursor.updatedAt` accepted any string and handed it to `::timestamptz`, so a malformed cursor surfaced as a 500 rather than a validation error. Validated against the text form Postgres actually emits. - The MCP tool restated the cursor shape instead of importing `pageListCursorSchema`, the drift `pageFields` warns about. Now shared, so the new validation applies at the agent boundary too. `PagesView` had no tests despite being where every bug found in this work lives. Each regression test was verified to fail against the unfixed code. Claude-Session: https://claude.ai/code/session_01M4rK4xzT8xUVXZZXgTw7KP --- .../components/PagesView/PagesView.test.tsx | 213 ++++++++++++++++++ .../pages/components/PagesView/PagesView.tsx | 8 +- .../pages/hooks/usePages/usePages.ts | 13 +- .../cli/src/commands/pages/list/command.ts | 23 +- packages/mcp/src/tools/pages/list.ts | 4 +- packages/trpc/src/router/page/schema.test.ts | 20 ++ packages/trpc/src/router/page/schema.ts | 5 +- 7 files changed, 261 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx new file mode 100644 index 00000000000..061fb21e3aa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx @@ -0,0 +1,213 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +// happy-dom is process-wide; unregister in afterAll so the shared mock +// document is restored for the other renderer suites. +const alreadyRegistered = GlobalRegistrator.isRegistered; +if (!alreadyRegistered) GlobalRegistrator.register(); +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +interface ListPage { + items: Array<{ + id: string; + title: string; + slug: string; + visibility: string; + }>; + nextCursor: { updatedAt: string; id: string } | null; +} + +interface InfiniteResult { + data?: { pages: ListPage[] }; + error?: { message: string } | null; + isPending: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + isFetchNextPageError: boolean; + fetchNextPage: () => void; +} + +const fetchNextPage = mock(() => {}); + +let listResult: InfiniteResult; + +function page(id: string, visibility = "org") { + return { id, title: `Page ${id}`, slug: `page-${id}`, visibility }; +} + +mock.module("renderer/lib/cloud-trpc", () => ({ + cloudTrpc: { + page: { + list: { useInfiniteQuery: () => listResult }, + delete: { useMutation: () => ({ mutateAsync: mock() }) }, + }, + useUtils: () => ({ + page: { + list: { + cancel: mock(async () => {}), + getInfiniteData: mock(() => undefined), + setInfiniteData: mock(), + invalidate: mock(), + }, + }, + }), + }, +})); + +mock.module("renderer/lib/auth-client", () => ({ + authClient: { useSession: () => ({ data: null }) }, +})); + +// The grid pulls in page cards, thumbnails and the whole UI kit. This suite is +// about what PagesView hands it, so record the props instead of rendering them. +let gridProps: { pages: unknown[]; error?: string; isPending: boolean }; +mock.module("../PagesGrid", () => ({ + PagesGrid: (props: typeof gridProps) => { + gridProps = props; + return null; + }, +})); + +const onScopeChange = mock((_scope: string) => {}); + +mock.module("./hooks/useCreatePageWithAgent", () => ({ + useCreatePageWithAgent: () => ({ + creatingWithAgent: false, + handleCreateWithAgent: mock(), + }), +})); + +mock.module("./hooks/usePageFavorites", () => ({ + usePageFavorites: () => ({ + favoritePageIdSet: new Set(), + toggleFavorite: mock(), + }), +})); + +mock.module( + "renderer/routes/_authenticated/_dashboard/hooks/useOpenPage", + () => ({ + useOpenPage: () => mock(), + isPaneModifier: () => false, + }), +); + +const { act, cleanup, render } = await import("@testing-library/react"); +const { PagesView } = await import("./PagesView"); + +function renderView(scope: "all" | "pinned" = "all") { + return render( + , + ); +} + +beforeEach(() => { + fetchNextPage.mockClear(); + onScopeChange.mockClear(); + listResult = { + data: { pages: [{ items: [page("a")], nextCursor: null }] }, + error: null, + isPending: false, + hasNextPage: false, + isFetchingNextPage: false, + isFetchNextPageError: false, + fetchNextPage, + }; +}); + +afterEach(cleanup); +afterAll(async () => { + if (!alreadyRegistered) await GlobalRegistrator.unregister(); +}); + +describe("PagesView drain", () => { + test("keeps pulling batches while the server reports more", async () => { + listResult.hasNextPage = true; + await act(async () => { + renderView(); + }); + expect(fetchNextPage).toHaveBeenCalled(); + }); + + test("does not re-arm while a batch is already in flight", async () => { + listResult.hasNextPage = true; + listResult.isFetchingNextPage = true; + await act(async () => { + renderView(); + }); + expect(fetchNextPage).not.toHaveBeenCalled(); + }); + + test("stops after a failed batch instead of looping forever", async () => { + listResult.hasNextPage = true; + listResult.isFetchNextPageError = true; + listResult.error = { message: "network down" }; + await act(async () => { + renderView(); + }); + expect(fetchNextPage).not.toHaveBeenCalled(); + }); +}); + +describe("PagesView error surfacing", () => { + test("keeps the loaded pages on screen when a later batch fails", async () => { + listResult.hasNextPage = true; + listResult.isFetchNextPageError = true; + listResult.error = { message: "network down" }; + await act(async () => { + renderView(); + }); + expect(gridProps.error).toBeUndefined(); + expect(gridProps.pages).toHaveLength(1); + }); + + test("shows the error when the first batch failed and nothing loaded", async () => { + listResult.data = { pages: [] }; + listResult.error = { message: "network down" }; + await act(async () => { + renderView(); + }); + expect(gridProps.error).toBe("network down"); + }); +}); + +describe("PagesView pinned tab", () => { + test("stays put while later batches could still contain pinned pages", async () => { + listResult.hasNextPage = true; + await act(async () => { + renderView("pinned"); + }); + expect(onScopeChange).not.toHaveBeenCalled(); + }); + + test("stays put on a cold mount, before the first batch has landed", async () => { + listResult.data = undefined; + listResult.isPending = true; + await act(async () => { + renderView("pinned"); + }); + expect(onScopeChange).not.toHaveBeenCalled(); + }); + + test("falls back to all once the sweep finished with nothing pinned", async () => { + await act(async () => { + renderView("pinned"); + }); + expect(onScopeChange).toHaveBeenCalledWith("all"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx index e53c846307c..30689ed5db1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx @@ -125,7 +125,11 @@ export function PagesView({ [counts.pinned, scope], ); - const pinnedEmpty = !hasNextPage && scope === "pinned" && counts.pinned === 0; + const pinnedEmpty = + pages.data !== undefined && + !hasNextPage && + scope === "pinned" && + counts.pinned === 0; const activeScope = pinnedEmpty ? "all" : scope; useEffect(() => { @@ -204,7 +208,7 @@ export function PagesView({ pinnedPageIds={favoritePageIdSet} currentUserId={session?.user.id} isPending={pages.isPending} - error={pages.error?.message} + error={all.length === 0 ? pages.error?.message : undefined} hasFilters={ !orgEmpty && (Boolean(search.trim()) || activeScope !== "all") } diff --git a/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts b/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts index c53b9e2f383..712a1b164dd 100644 --- a/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts +++ b/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts @@ -17,14 +17,17 @@ export function usePagesQuery(): UseQueryResult { return useQuery({ queryKey: ["cloud", "page", "list", organizationId], enabled: organizationId !== null, - queryFn: async () => { + queryFn: async ({ signal }) => { const items: OrgPage[] = []; let cursor: { updatedAt: string; id: string } | undefined; do { - const result = await apiClient.page.list.query({ - limit: PAGES_PER_REQUEST, - ...(cursor ? { cursor } : {}), - }); + const result = await apiClient.page.list.query( + { + limit: PAGES_PER_REQUEST, + ...(cursor ? { cursor } : {}), + }, + { signal }, + ); items.push(...result.items); cursor = result.nextCursor ?? undefined; } while (cursor); diff --git a/packages/cli/src/commands/pages/list/command.ts b/packages/cli/src/commands/pages/list/command.ts index 016d18b140e..b918e406035 100644 --- a/packages/cli/src/commands/pages/list/command.ts +++ b/packages/cli/src/commands/pages/list/command.ts @@ -1,4 +1,4 @@ -import { CLIError, number, string, table } from "@superset/cli-framework"; +import { number, string, table } from "@superset/cli-framework"; import { PAGE_LIST_MAX_LIMIT } from "@superset/trpc/page-schema"; import { command } from "../../../lib/command"; import { @@ -20,23 +20,16 @@ export default command({ search: string() .alias("q") .desc("Only pages whose title or slug contains this text"), - limit: number().desc( - `Return one batch of this many pages (1-${PAGE_LIST_MAX_LIMIT}) plus a cursor, instead of every page`, - ), + limit: number() + .int() + .min(1) + .max(PAGE_LIST_MAX_LIMIT) + .desc( + `Return one batch of this many pages (1-${PAGE_LIST_MAX_LIMIT}) plus a cursor, instead of every page`, + ), cursor: string().desc("Continue from a previous run's nextCursor"), }, run: async ({ ctx, options }) => { - if (options.limit !== undefined) { - if (!Number.isInteger(options.limit)) { - throw new CLIError(`--limit must be a whole number`); - } - if (options.limit < 1 || options.limit > PAGE_LIST_MAX_LIMIT) { - throw new CLIError( - `--limit must be between 1 and ${PAGE_LIST_MAX_LIMIT}`, - ); - } - } - const workspace = options.workspace ?? process.env.SUPERSET_WORKSPACE_ID; const workspaceId = workspace ? await resolveWorkspaceId({ diff --git a/packages/mcp/src/tools/pages/list.ts b/packages/mcp/src/tools/pages/list.ts index cef5e62a05d..90ad3202afc 100644 --- a/packages/mcp/src/tools/pages/list.ts +++ b/packages/mcp/src/tools/pages/list.ts @@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { PAGE_LIST_DEFAULT_LIMIT, PAGE_LIST_MAX_LIMIT, + pageListCursorSchema, } from "@superset/trpc/page-schema"; import { z } from "zod"; import { createMcpCaller } from "../../caller"; @@ -38,8 +39,7 @@ export function register(server: McpServer): void { .describe( `How many pages to return, 1-${PAGE_LIST_MAX_LIMIT} (default ${PAGE_LIST_DEFAULT_LIMIT}).`, ), - cursor: z - .object({ updatedAt: z.string(), id: z.string().uuid() }) + cursor: pageListCursorSchema .nullish() .describe( "The `nextCursor` object from a previous call, passed back unchanged, to fetch the next batch. Omit for the first batch.", diff --git a/packages/trpc/src/router/page/schema.test.ts b/packages/trpc/src/router/page/schema.test.ts index 4117b49c816..d376458be05 100644 --- a/packages/trpc/src/router/page/schema.test.ts +++ b/packages/trpc/src/router/page/schema.test.ts @@ -119,4 +119,24 @@ describe("listPagesSchema", () => { false, ); }); + + test("refuses a timestamp Postgres would reject, rather than passing it to the cast", () => { + expect( + listPagesSchema.safeParse({ + cursor: { updatedAt: "not-a-timestamp", id: PAGE }, + }).success, + ).toBe(false); + }); + + test("accepts the offsets Postgres emits, whole-hour and half-hour alike", () => { + for (const updatedAt of [ + "2026-09-14 10:00:00+00", + "2026-09-14 10:00:00.123456+05:30", + "2026-09-14 10:00:00.1-08", + ]) { + expect( + listPagesSchema.safeParse({ cursor: { updatedAt, id: PAGE } }), + ).toMatchObject({ success: true }); + } + }); }); diff --git a/packages/trpc/src/router/page/schema.ts b/packages/trpc/src/router/page/schema.ts index 91e55633422..694cb8287e9 100644 --- a/packages/trpc/src/router/page/schema.ts +++ b/packages/trpc/src/router/page/schema.ts @@ -108,8 +108,11 @@ export type CreatePageInput = z.infer; export const PAGE_LIST_DEFAULT_LIMIT = 50; export const PAGE_LIST_MAX_LIMIT = 200; +const POSTGRES_TIMESTAMPTZ_TEXT = + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,6})?[+-]\d{2}(:\d{2}(:\d{2})?)?$/; + export const pageListCursorSchema = z.object({ - updatedAt: z.string().min(1).max(64), + updatedAt: z.string().max(64).regex(POSTGRES_TIMESTAMPTZ_TEXT), id: pageFields.id, }); From 30375bf5c8e768dc2e302690a017c5c39a1b837e Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Mon, 14 Sep 2026 15:17:52 +0530 Subject: [PATCH 03/16] docs(page-skill): document --search, --limit and --cursor on pages list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill described `pages list` as it behaved before pagination. Drain-by- default kept that accurate, so nothing there was wrong — it just stopped being complete. The part an agent gets wrong without being told is the output shape: plain `list` prints a bare array, but passing either `--limit` or `--cursor` switches it to `{ items, nextCursor }`. The flags themselves are discoverable from `--help`; that switch is not. Body-only edit, so the generated manifests are untouched — `publishedSkills` captures `name` and `description` from frontmatter and nothing else. No version bump: no `superset@*` tag exists, so `checkPlugin` has no release to compare the tree against and `check:plugins` is green. Claude-Session: https://claude.ai/code/session_01M4rK4xzT8xUVXZZXgTw7KP --- plugins/superset/skills/page/SKILL.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/superset/skills/page/SKILL.md b/plugins/superset/skills/page/SKILL.md index d3e62d98695..a45ec0ac01f 100644 --- a/plugins/superset/skills/page/SKILL.md +++ b/plugins/superset/skills/page/SKILL.md @@ -304,6 +304,7 @@ until someone widens it. ```bash superset pages list --workspace # or omit --workspace for the whole org +superset pages list --search "Q3 close" # -q also works; matches title or slug superset pages get superset pages versions superset pages pull --version 2 > v2.html @@ -312,6 +313,14 @@ superset pages pull --version 2 > v2.html `pull` writes HTML to stdout; use it to recover a source file you no longer have, or to diff what actually shipped against what you have locally. +`list` returns every page it can see, so reach for `--search` before you reach +for a pipe into `grep`. Two flags change that: `--limit <1-200>` returns one +batch, and `--cursor` continues from where a batch stopped. Passing either one +also changes the output shape — plain `list` prints a bare array, while a +batched run prints `{ items, nextCursor }` and you pass that `nextCursor` back +as `--cursor` until it comes back `null`. Scripts that expect an array should +just not pass the flags. + ## Answer comments A reader clicks an element on the published page and pins a comment to it. When From b6dcf4d023442ace4d8143955b45802184fb52a8 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Mon, 14 Sep 2026 18:31:28 +0530 Subject: [PATCH 04/16] feat(pages): say so when the background sweep stops short MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeping the loaded pages on screen after a failed batch fixed the blanking, but left a quieter problem: the drain stops, the list ends short, and nothing says so. The tab counts then under-report and the search box only searches what loaded. Before pagination there was a single request, so this partial state did not exist. A non-blocking line above the grid names it and offers Retry. Retrying clears `isFetchNextPageError`, which re-arms the drain effect, so one click resumes the sweep rather than fetching a single batch. One new string; `Retry` was already in the catalogs. Translated into all 16 non-English locales — `compile --strict` fails the build on a missing one. Claude-Session: https://claude.ai/code/session_01M4rK4xzT8xUVXZZXgTw7KP --- .../components/PagesView/PagesView.test.tsx | 20 +++++++++++++++++++ .../pages/components/PagesView/PagesView.tsx | 17 ++++++++++++++++ packages/i18n/locales/cs/messages.po | 3 +++ packages/i18n/locales/de/messages.po | 3 +++ packages/i18n/locales/en/messages.po | 3 +++ packages/i18n/locales/es/messages.po | 3 +++ packages/i18n/locales/fr/messages.po | 3 +++ packages/i18n/locales/id/messages.po | 3 +++ packages/i18n/locales/it/messages.po | 3 +++ packages/i18n/locales/ja/messages.po | 3 +++ packages/i18n/locales/ko/messages.po | 3 +++ packages/i18n/locales/nl/messages.po | 3 +++ packages/i18n/locales/pl/messages.po | 3 +++ packages/i18n/locales/pt-BR/messages.po | 3 +++ packages/i18n/locales/ru/messages.po | 3 +++ packages/i18n/locales/tr/messages.po | 3 +++ packages/i18n/locales/vi/messages.po | 3 +++ packages/i18n/locales/zh-CN/messages.po | 3 +++ packages/i18n/locales/zh-TW/messages.po | 3 +++ 19 files changed, 88 insertions(+) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx index 061fb21e3aa..1ef1618196b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx @@ -176,6 +176,26 @@ describe("PagesView error surfacing", () => { expect(gridProps.pages).toHaveLength(1); }); + test("says the list is short, and retries the batch on demand", async () => { + listResult.hasNextPage = true; + listResult.isFetchNextPageError = true; + listResult.error = { message: "network down" }; + const view = await act(async () => renderView()); + const retry = view.getByRole("button", { name: "Retry" }); + expect(retry).toBeTruthy(); + fetchNextPage.mockClear(); + await act(async () => { + retry.click(); + }); + expect(fetchNextPage).toHaveBeenCalled(); + }); + + test("stays quiet while the sweep is still going fine", async () => { + listResult.hasNextPage = true; + const view = await act(async () => renderView()); + expect(view.queryByRole("button", { name: "Retry" })).toBeNull(); + }); + test("shows the error when the first batch failed and nothing loaded", async () => { listResult.data = { pages: [] }; listResult.error = { message: "network down" }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx index 30689ed5db1..c10880c5cab 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx @@ -201,6 +201,23 @@ export function PagesView({ )} + {isFetchNextPageError && all.length > 0 && ( +
+ + + Some pages couldn't load, so this list is incomplete. + + + +
+ )} + Date: Mon, 14 Sep 2026 18:31:28 +0530 Subject: [PATCH 05/16] docs(page-skill): scope the output-shape note to --json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paragraph said a plain `list` prints a bare array. It does not — `pages list` defines a `display`, so `formatOutput` renders a table unless `--json` or `--quiet` is passed. Only `--json` ever shows the array or the `{ items, nextCursor }` envelope, and `--quiet` prints ids for both. Conflating the returned data with the printed output is exactly the kind of thing an agent would act on, so the two are now described separately. Claude-Session: https://claude.ai/code/session_01M4rK4xzT8xUVXZZXgTw7KP --- plugins/superset/skills/page/SKILL.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugins/superset/skills/page/SKILL.md b/plugins/superset/skills/page/SKILL.md index a45ec0ac01f..75dbdfdc708 100644 --- a/plugins/superset/skills/page/SKILL.md +++ b/plugins/superset/skills/page/SKILL.md @@ -314,12 +314,15 @@ superset pages pull --version 2 > v2.html have, or to diff what actually shipped against what you have locally. `list` returns every page it can see, so reach for `--search` before you reach -for a pipe into `grep`. Two flags change that: `--limit <1-200>` returns one -batch, and `--cursor` continues from where a batch stopped. Passing either one -also changes the output shape — plain `list` prints a bare array, while a -batched run prints `{ items, nextCursor }` and you pass that `nextCursor` back -as `--cursor` until it comes back `null`. Scripts that expect an array should -just not pass the flags. +for a pipe into `grep`. Two flags change that: `--limit <1-200>` returns a +single batch, and `--cursor` continues from where a batch stopped. + +They also change the JSON. Under `--json`, a plain `list` is a bare array, but +passing either flag wraps it as `{ items, nextCursor }` — feed that `nextCursor` +back as `--cursor` until it comes back `null`. Parsing the output? Either don't +pass the flags, or handle the envelope. The other two modes are unaffected: the +default table looks the same and tells you when there's more, and `--quiet` +prints ids either way. ## Answer comments From 846b443a1763c21b167fe079b586a1e8d864226c Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Sun, 20 Sep 2026 21:24:23 +0530 Subject: [PATCH 06/16] docs(page-skill): drop the em dash the prose rule forbids main added a test in agent-setup that fails the build on an em dash in a bundled skill. The pagination note predates it. --- plugins/superset/skills/page/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/superset/skills/page/SKILL.md b/plugins/superset/skills/page/SKILL.md index 568c983deb5..7cc68e5efb3 100644 --- a/plugins/superset/skills/page/SKILL.md +++ b/plugins/superset/skills/page/SKILL.md @@ -341,7 +341,7 @@ for a pipe into `grep`. Two flags change that: `--limit <1-200>` returns a single batch, and `--cursor` continues from where a batch stopped. They also change the JSON. Under `--json`, a plain `list` is a bare array, but -passing either flag wraps it as `{ items, nextCursor }` — feed that `nextCursor` +passing either flag wraps it as `{ items, nextCursor }`. Feed that `nextCursor` back as `--cursor` until it comes back `null`. Parsing the output? Either don't pass the flags, or handle the envelope. The other two modes are unaffected: the default table looks the same and tells you when there's more, and `--quiet` From 52b28df17909a4a8cd76f3414399523c64f8348c Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 01:18:48 +0530 Subject: [PATCH 07/16] feat(pages): surface which workspace a page was published from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `page.list` joined workspace_pages only to filter by it, so no caller could tell where a page came from. 228 pages in an org came back with no workspace field at all, and finding a page again after a long session meant opening them. The links are read after the keyset rather than joined into it. A page carries one workspace_pages row per workspace it was published from — publish.ts does onConflictDoNothing against the (workspaceId, pageId) primary key, so a second workspace adds a row rather than replacing one. Joining would have spent `limit + 1` on rows instead of pages and repeated an id across the cursor boundary, degrading the pagination this branch exists to add. Names are resolved client-side, never on the server. Workspace names live in the host's SQLite; the cloud database has only ids, and cloud_workspaces.name is documented as a provisioning input that a rename leaves stale. So the CLI maps ids through the host's workspace list and falls back to a short id when a page came from another machine, and the desktop filter offers only workspaces this machine can name. `pages list` also announces the workspace filter it applies from $SUPERSET_WORKSPACE_ID. Every Superset terminal sets that variable, so a command describing itself as "List pages in the organization" was silently scoped to one workspace, and an empty workspace was indistinguishable from an empty org. Only the implicit filter is announced — someone who passed --workspace already knows — and `--workspace ''` now opts back out to the org. --- .../components/PagesView/PagesView.test.tsx | 9 ++ .../pages/components/PagesView/PagesView.tsx | 43 ++++++- .../WorkspaceFilter/WorkspaceFilter.tsx | 110 ++++++++++++++++++ .../components/WorkspaceFilter/index.ts | 1 + .../_authenticated/_dashboard/pages/page.tsx | 20 +++- .../utils/filterPages/filterPages.test.ts | 34 ++++++ .../pages/utils/filterPages/filterPages.ts | 16 ++- .../src/commands/pages/list/command.test.ts | 83 +++++++++++++ .../cli/src/commands/pages/list/command.ts | 83 +++++++++++-- packages/i18n/locales/cs/messages.po | 6 + packages/i18n/locales/de/messages.po | 6 + packages/i18n/locales/en/messages.po | 6 + packages/i18n/locales/es/messages.po | 6 + packages/i18n/locales/fr/messages.po | 6 + packages/i18n/locales/id/messages.po | 6 + packages/i18n/locales/it/messages.po | 6 + packages/i18n/locales/ja/messages.po | 6 + packages/i18n/locales/ko/messages.po | 6 + packages/i18n/locales/nl/messages.po | 6 + packages/i18n/locales/pl/messages.po | 6 + packages/i18n/locales/pt-BR/messages.po | 6 + packages/i18n/locales/ru/messages.po | 6 + packages/i18n/locales/tr/messages.po | 6 + packages/i18n/locales/vi/messages.po | 6 + packages/i18n/locales/zh-CN/messages.po | 6 + packages/i18n/locales/zh-TW/messages.po | 6 + packages/trpc/src/router/page/page.ts | 27 +++++ 27 files changed, 514 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/WorkspaceFilter/WorkspaceFilter.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/WorkspaceFilter/index.ts create mode 100644 packages/cli/src/commands/pages/list/command.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx index f8665f7c000..5b49e5987b5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx @@ -105,6 +105,13 @@ mock.module( }), ); +mock.module( + "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces", + () => ({ + useAccessibleV2Workspaces: () => ({ all: [] }), + }), +); + const { act, cleanup, render } = await import("@testing-library/react"); const { PagesView } = await import("./PagesView"); @@ -114,9 +121,11 @@ function renderView(scope: "all" | "pinned" = "all") { search="" scope={scope} authorId={null} + workspaceId={null} onSearchChange={mock()} onScopeChange={onScopeChange} onAuthorChange={mock()} + onWorkspaceChange={mock()} />, ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx index 601eece4423..16743435b55 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx @@ -17,6 +17,7 @@ import { useOpenPage, } from "renderer/routes/_authenticated/_dashboard/hooks/useOpenPage"; import { usePageFavorites } from "renderer/routes/_authenticated/_dashboard/hooks/usePageFavorites"; +import { useAccessibleV2Workspaces } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; import { filterPages, matchesScope, @@ -25,6 +26,10 @@ import { } from "../../utils/filterPages"; import { PagesGrid } from "../PagesGrid"; import { AuthorFilter, type PageAuthorOption } from "./components/AuthorFilter"; +import { + type PageWorkspaceOption, + WorkspaceFilter, +} from "./components/WorkspaceFilter"; import { useCreatePageWithAgent } from "./hooks/useCreatePageWithAgent"; const PAGES_QUERY = pagesListInput(); @@ -40,18 +45,22 @@ interface PagesViewProps { search: string; scope: PageScope; authorId: string | null; + workspaceId: string | null; onSearchChange: (search: string) => void; onScopeChange: (scope: PageScope) => void; onAuthorChange: (authorId: string | null) => void; + onWorkspaceChange: (workspaceId: string | null) => void; } export function PagesView({ search, scope, authorId, + workspaceId, onSearchChange, onScopeChange, onAuthorChange, + onWorkspaceChange, }: PagesViewProps) { const { t } = useLingui(); const { creatingWithAgent, handleCreateWithAgent } = useCreatePageWithAgent(); @@ -122,6 +131,27 @@ export function PagesView({ }); }, [all, currentUserId, t]); + const { all: accessibleWorkspaces } = useAccessibleV2Workspaces(); + const workspaceOptions = useMemo(() => { + const names = new Map( + accessibleWorkspaces.map((workspace) => [workspace.id, workspace.name]), + ); + const counts = new Map(); + for (const page of all) { + for (const link of page.workspaceLinks ?? []) { + counts.set(link.workspaceId, (counts.get(link.workspaceId) ?? 0) + 1); + } + } + return Array.from(counts.entries()) + .filter(([id]) => names.has(id)) + .map(([id, count]) => ({ + workspaceId: id, + name: names.get(id) ?? id, + count, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [all, accessibleWorkspaces]); + const counts = useMemo( () => ({ all: all.length, @@ -162,10 +192,11 @@ export function PagesView({ scope: activeScope, pinnedPageIds: favoritePageIdSet, authorId, + workspaceId, }), favoritePageIdSet, ), - [all, search, activeScope, favoritePageIdSet, authorId], + [all, search, activeScope, favoritePageIdSet, authorId, workspaceId], ); const orgEmpty = !pages.isPending && !pages.error && all.length === 0; @@ -207,6 +238,13 @@ export function PagesView({
+ {(workspaceOptions.length > 1 || workspaceId !== null) && ( + + )} {(authorOptions.length > 1 || authorId !== null) && ( openPage( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/WorkspaceFilter/WorkspaceFilter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/WorkspaceFilter/WorkspaceFilter.tsx new file mode 100644 index 00000000000..3f794a4e849 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/WorkspaceFilter/WorkspaceFilter.tsx @@ -0,0 +1,110 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { Button } from "@superset/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { cn } from "@superset/ui/utils"; +import { Check, ChevronDown, LayoutGrid } from "lucide-react"; +import { useState } from "react"; + +export interface PageWorkspaceOption { + workspaceId: string; + name: string; + count: number; +} + +interface WorkspaceFilterProps { + value: string | null; + options: PageWorkspaceOption[]; + onChange: (value: string | null) => void; +} + +export function WorkspaceFilter({ + value, + options, + onChange, +}: WorkspaceFilterProps) { + const { t } = useLingui(); + const [open, setOpen] = useState(false); + + const selected = + options.find((option) => option.workspaceId === value) ?? null; + const label = selected + ? selected.name + : value + ? t({ message: "Unknown workspace" }) + : t({ message: "All workspaces" }); + + const select = (next: string | null) => { + onChange(next); + setOpen(false); + }; + + return ( + + + + + + + + + + No workspaces found. + + + select(null)} + > + + + All workspaces + + {value === null && ( + + )} + + {options.map((option) => ( + select(option.workspaceId)} + > + + {option.name} + + {option.count} + + {value === option.workspaceId && ( + + )} + + ))} + + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/WorkspaceFilter/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/WorkspaceFilter/index.ts new file mode 100644 index 00000000000..d4447f614a8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/components/WorkspaceFilter/index.ts @@ -0,0 +1 @@ +export { type PageWorkspaceOption, WorkspaceFilter } from "./WorkspaceFilter"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/page.tsx index 8b524d68cc4..b23811706f8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/page.tsx @@ -5,7 +5,12 @@ import { Redirect } from "renderer/components/Redirect"; import { PagesView } from "./components/PagesView"; import { isPageScope, type PageScope } from "./utils/filterPages"; -export type PagesSearch = { q?: string; scope?: PageScope; author?: string }; +export type PagesSearch = { + q?: string; + scope?: PageScope; + author?: string; + workspace?: string; +}; export const Route = createFileRoute("/_authenticated/_dashboard/pages/")({ component: PagesPage, @@ -16,11 +21,15 @@ export const Route = createFileRoute("/_authenticated/_dashboard/pages/")({ typeof search.author === "string" && search.author ? search.author : undefined, + workspace: + typeof search.workspace === "string" && search.workspace + ? search.workspace + : undefined, }), }); function PagesPage() { - const { q, scope, author } = Route.useSearch(); + const { q, scope, author, workspace } = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); const isEnabled = useFeatureFlagEnabled(FEATURE_FLAGS.PAGES); @@ -32,6 +41,7 @@ function PagesPage() { search={q ?? ""} scope={scope ?? "all"} authorId={author ?? null} + workspaceId={workspace ?? null} onSearchChange={(value) => navigate({ search: (prev) => ({ ...prev, q: value || undefined }), @@ -53,6 +63,12 @@ function PagesPage() { replace: true, }) } + onWorkspaceChange={(value) => + navigate({ + search: (prev) => ({ ...prev, workspace: value ?? undefined }), + replace: true, + }) + } /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts index 361a083c00c..42fd6fa8ee5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts @@ -5,6 +5,7 @@ import { matchesAuthor, matchesScope, matchesSearch, + matchesWorkspace, sortPinnedFirst, } from "./filterPages"; @@ -124,6 +125,39 @@ describe("filterPages", () => { }); }); +describe("matchesWorkspace", () => { + const linked = { + ...team, + workspaceLinks: [{ workspaceId: "ws-a" }, { workspaceId: "ws-b" }], + }; + + it("keeps every page when no workspace is selected", () => { + expect(matchesWorkspace(other, null)).toBe(true); + }); + + it("matches a page linked to the selected workspace", () => { + expect(matchesWorkspace(linked, "ws-b")).toBe(true); + }); + + it("rejects a page linked only to other workspaces", () => { + expect(matchesWorkspace(linked, "ws-c")).toBe(false); + }); + + it("rejects a page with no workspace links once one is selected", () => { + expect(matchesWorkspace(other, "ws-a")).toBe(false); + }); + + it("filters alongside scope and author", () => { + const result = filterPages([linked, mine, other], { + search: "", + scope: "all", + pinnedPageIds: new Set(), + workspaceId: "ws-a", + }); + expect(result.map((p) => p.id)).toEqual(["1"]); + }); +}); + describe("sortPinnedFirst", () => { it("floats pinned pages while preserving relative order", () => { const sorted = sortPinnedFirst([team, mine, other], new Set(["3"])); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts index ea73ff08672..0307641e1e5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts @@ -13,6 +13,7 @@ export interface FilterablePage { visibility: string; description?: string | null; createdByUserId?: string | null; + workspaceLinks?: readonly { workspaceId: string }[]; } export function matchesSearch(page: FilterablePage, query: string): boolean { @@ -48,6 +49,16 @@ export function matchesAuthor( return page.createdByUserId === authorId; } +export function matchesWorkspace( + page: FilterablePage, + workspaceId: string | null, +): boolean { + if (!workspaceId) return true; + return Boolean( + page.workspaceLinks?.some((link) => link.workspaceId === workspaceId), + ); +} + export function filterPages( pages: T[], { @@ -55,18 +66,21 @@ export function filterPages( scope, pinnedPageIds, authorId = null, + workspaceId = null, }: { search: string; scope: PageScope; pinnedPageIds: ReadonlySet; authorId?: string | null; + workspaceId?: string | null; }, ): T[] { return pages.filter( (page) => matchesSearch(page, search) && matchesScope(page, scope, pinnedPageIds) && - matchesAuthor(page, authorId), + matchesAuthor(page, authorId) && + matchesWorkspace(page, workspaceId), ); } diff --git a/packages/cli/src/commands/pages/list/command.test.ts b/packages/cli/src/commands/pages/list/command.test.ts new file mode 100644 index 00000000000..8990b213e30 --- /dev/null +++ b/packages/cli/src/commands/pages/list/command.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { nameLinks, workspaceCell } from "./command"; + +const WORKSPACE_A = "11111111-1111-4111-8111-111111111111"; +const WORKSPACE_B = "22222222-2222-4222-8222-222222222222"; + +type Link = { workspaceId: string; entryPath: string }; + +const row = (links?: Link[]) => ({ + id: "page-1", + title: "Q3 pipeline", + workspaceLinks: links, +}); + +function named(links: Link[], names: Map) { + const [first] = nameLinks([row(links)], names); + if (!first) throw new Error("nameLinks dropped the row"); + return first; +} + +describe("workspaceCell", () => { + test("prefers the resolved name over the id", () => { + expect( + workspaceCell( + named( + [{ workspaceId: WORKSPACE_A, entryPath: "report.html" }], + new Map([[WORKSPACE_A, "pipeline-review"]]), + ), + ), + ).toBe("pipeline-review"); + }); + + test("falls back to a short id when the workspace is on another machine", () => { + expect( + workspaceCell(row([{ workspaceId: WORKSPACE_A, entryPath: "a.html" }])), + ).toBe("11111111"); + }); + + test("counts the rest when a page was published from several workspaces", () => { + expect( + workspaceCell( + named( + [ + { workspaceId: WORKSPACE_A, entryPath: "a.html" }, + { workspaceId: WORKSPACE_B, entryPath: "b.html" }, + ], + new Map([[WORKSPACE_A, "pipeline-review"]]), + ), + ), + ).toBe("pipeline-review +1"); + }); + + test("marks a page that belongs to no workspace", () => { + expect(workspaceCell(row([]))).toBe("—"); + expect(workspaceCell(row())).toBe("—"); + }); +}); + +describe("nameLinks", () => { + test("leaves rows untouched when no workspace name is known", () => { + const rows = [row([{ workspaceId: WORKSPACE_A, entryPath: "a.html" }])]; + expect(nameLinks(rows, new Map())).toBe(rows); + }); + + test("names only the links it can resolve", () => { + expect( + named( + [ + { workspaceId: WORKSPACE_A, entryPath: "a.html" }, + { workspaceId: WORKSPACE_B, entryPath: "b.html" }, + ], + new Map([[WORKSPACE_A, "pipeline-review"]]), + ).workspaceLinks, + ).toEqual([ + { + workspaceId: WORKSPACE_A, + entryPath: "a.html", + name: "pipeline-review", + }, + { workspaceId: WORKSPACE_B, entryPath: "b.html" }, + ]); + }); +}); diff --git a/packages/cli/src/commands/pages/list/command.ts b/packages/cli/src/commands/pages/list/command.ts index b918e406035..9f3ab9c36de 100644 --- a/packages/cli/src/commands/pages/list/command.ts +++ b/packages/cli/src/commands/pages/list/command.ts @@ -1,6 +1,7 @@ import { number, string, table } from "@superset/cli-framework"; import { PAGE_LIST_MAX_LIMIT } from "@superset/trpc/page-schema"; -import { command } from "../../../lib/command"; +import { type CliContext, command } from "../../../lib/command"; +import { listWorkspacesOnHost } from "../../../lib/host-workspaces"; import { decodeCursor, encodeCursor, @@ -9,13 +10,62 @@ import { } from "../pageList"; import { resolveWorkspaceId } from "../workspaceRef"; -type PageRow = Record; +interface WorkspaceLink { + workspaceId: string; + entryPath: string; + name?: string; +} + +type PageRow = Record & { workspaceLinks?: WorkspaceLink[] }; + +async function workspaceNames(ctx: CliContext): Promise> { + const organizationId = ctx.config.organizationId; + if (!organizationId) return new Map(); + try { + const { workspaces } = await listWorkspacesOnHost({ + organizationId, + userJwt: ctx.bearer, + api: ctx.api, + }); + return new Map( + workspaces.map((workspace) => [workspace.id, workspace.name]), + ); + } catch { + return new Map(); + } +} + +export function nameLinks( + rows: PageRow[], + names: Map, +): PageRow[] { + if (names.size === 0) return rows; + return rows.map((row) => + row.workspaceLinks + ? { + ...row, + workspaceLinks: row.workspaceLinks.map((link) => { + const name = names.get(link.workspaceId); + return name ? { ...link, name } : link; + }), + } + : row, + ); +} + +export function workspaceCell(row: PageRow): string { + const links = row.workspaceLinks ?? []; + const [first] = links; + if (!first) return "—"; + const label = first.name ?? first.workspaceId.slice(0, 8); + return links.length > 1 ? `${label} +${links.length - 1}` : label; +} export default command({ description: "List pages in the organization", options: { workspace: string().desc( - "Only pages published from this workspace, by name or id (defaults to $SUPERSET_WORKSPACE_ID)", + "Only pages published from this workspace, by name or id (defaults to $SUPERSET_WORKSPACE_ID; pass '' for the whole org)", ), search: string() .alias("q") @@ -30,7 +80,10 @@ export default command({ cursor: string().desc("Continue from a previous run's nextCursor"), }, run: async ({ ctx, options }) => { - const workspace = options.workspace ?? process.env.SUPERSET_WORKSPACE_ID; + const asked = options.workspace !== undefined; + const workspace = asked + ? options.workspace + : process.env.SUPERSET_WORKSPACE_ID; const workspaceId = workspace ? await resolveWorkspaceId({ value: workspace, @@ -40,6 +93,15 @@ export default command({ }) : undefined; + const names = await workspaceNames(ctx); + + if (workspaceId && !asked) { + const label = names.get(workspaceId) ?? workspaceId; + process.stderr.write( + `Showing pages from the current workspace (${label}). Pass --workspace '' for the whole organization.\n`, + ); + } + const query = { ...(workspaceId ? { workspaceId } : {}), ...(options.search ? { search: options.search } : {}), @@ -47,7 +109,9 @@ export default command({ }; if (options.limit === undefined && options.cursor === undefined) { - return { data: await fetchAllPages(ctx, query) }; + return { + data: nameLinks(await fetchAllPages(ctx, query), names), + }; } const result = await fetchPageList( @@ -57,7 +121,7 @@ export default command({ ); return { data: { - items: result.items, + items: nameLinks(result.items, names), nextCursor: result.nextCursor ? encodeCursor(result.nextCursor) : null, }, }; @@ -75,12 +139,13 @@ export default command({ title: row.title, version: row.latestVersion, visibility: row.visibility, + workspace: workspaceCell(row), url: row.url, id: row.id, })), - ["title", "version", "visibility", "url", "id"], - ["TITLE", "V", "VISIBILITY", "URL", "ID"], - [30, 4, 10, 50, 36], + ["title", "version", "visibility", "workspace", "url", "id"], + ["TITLE", "V", "VISIBILITY", "WORKSPACE", "URL", "ID"], + [30, 4, 10, 26, 50, 36], ); if (!nextCursor) return rendered; return `${rendered}\n\nMore pages available — re-run with --cursor ${nextCursor}, or drop --limit to fetch every page.`; diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index fbca355bc07..7fe9c10df9c 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -6610,6 +6610,9 @@ msgstr "Filtrovat podle revizí" msgid "Filter by state" msgstr "Filtrovat podle stavu" +msgid "Filter by workspace: {label}" +msgstr "Filtrovat podle pracovního prostoru: {label}" + msgid "Filter hosts" msgstr "Filtrovat hosty" @@ -16002,6 +16005,9 @@ msgstr "Neznámé proměnné: {unknownVariables}" msgid "Unknown Welder" msgstr "Neznámý svářeč" +msgid "Unknown workspace" +msgstr "Neznámý pracovní prostor" + msgid "Unlimited" msgstr "Neomezeně" diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index 90a49101480..8c862fcded0 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -6610,6 +6610,9 @@ msgstr "Nach Reviews filtern" msgid "Filter by state" msgstr "Nach Status filtern" +msgid "Filter by workspace: {label}" +msgstr "Nach Arbeitsbereich filtern: {label}" + msgid "Filter hosts" msgstr "Hosts filtern" @@ -16002,6 +16005,9 @@ msgstr "Unbekannte Variablen: {unknownVariables}" msgid "Unknown Welder" msgstr "Unbekannter Schweißer" +msgid "Unknown workspace" +msgstr "Unbekannter Arbeitsbereich" + msgid "Unlimited" msgstr "Unbegrenzt" diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index 7806caa161a..0067cd19792 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -6610,6 +6610,9 @@ msgstr "Filter by reviews" msgid "Filter by state" msgstr "Filter by state" +msgid "Filter by workspace: {label}" +msgstr "Filter by workspace: {label}" + msgid "Filter hosts" msgstr "Filter hosts" @@ -16002,6 +16005,9 @@ msgstr "Unknown variables: {unknownVariables}" msgid "Unknown Welder" msgstr "Unknown Welder" +msgid "Unknown workspace" +msgstr "Unknown workspace" + msgid "Unlimited" msgstr "Unlimited" diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index ed070de67af..8b5d0a70937 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -6610,6 +6610,9 @@ msgstr "Filtrar por revisiones" msgid "Filter by state" msgstr "Filtrar por estado" +msgid "Filter by workspace: {label}" +msgstr "Filtrar por espacio de trabajo: {label}" + msgid "Filter hosts" msgstr "Filtrar hosts" @@ -16002,6 +16005,9 @@ msgstr "Variables desconocidas: {unknownVariables}" msgid "Unknown Welder" msgstr "Soldador desconocido" +msgid "Unknown workspace" +msgstr "Espacio de trabajo desconocido" + msgid "Unlimited" msgstr "Ilimitado" diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index c2779b9a9e2..fcb2e6cb6ea 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -6610,6 +6610,9 @@ msgstr "Filtrer par revues" msgid "Filter by state" msgstr "Filtrer par état" +msgid "Filter by workspace: {label}" +msgstr "Filtrer par espace de travail : {label}" + msgid "Filter hosts" msgstr "Filtrer les hôtes" @@ -16002,6 +16005,9 @@ msgstr "Variables inconnues : {unknownVariables}" msgid "Unknown Welder" msgstr "Soudeur inconnu" +msgid "Unknown workspace" +msgstr "Espace de travail inconnu" + msgid "Unlimited" msgstr "Illimité" diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index e822c707a87..d074012a452 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -6610,6 +6610,9 @@ msgstr "Filter menurut review" msgid "Filter by state" msgstr "Filter menurut status" +msgid "Filter by workspace: {label}" +msgstr "Filter menurut workspace: {label}" + msgid "Filter hosts" msgstr "Filter host" @@ -16002,6 +16005,9 @@ msgstr "Variabel tidak dikenal: {unknownVariables}" msgid "Unknown Welder" msgstr "Tukang Las Tak Dikenal" +msgid "Unknown workspace" +msgstr "Workspace tidak dikenal" + msgid "Unlimited" msgstr "Tanpa batas" diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index 21007e2ed41..d95c0737e9c 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -6610,6 +6610,9 @@ msgstr "Filtra per revisioni" msgid "Filter by state" msgstr "Filtra per stato" +msgid "Filter by workspace: {label}" +msgstr "Filtra per workspace: {label}" + msgid "Filter hosts" msgstr "Filtra gli host" @@ -16002,6 +16005,9 @@ msgstr "Variabili sconosciute: {unknownVariables}" msgid "Unknown Welder" msgstr "Saldatore Sconosciuto" +msgid "Unknown workspace" +msgstr "Workspace sconosciuto" + msgid "Unlimited" msgstr "Illimitati" diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index fca903a75ac..e72739fcf5b 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -6610,6 +6610,9 @@ msgstr "レビューで絞り込む" msgid "Filter by state" msgstr "状態で絞り込む" +msgid "Filter by workspace: {label}" +msgstr "ワークスペースで絞り込み: {label}" + msgid "Filter hosts" msgstr "ホストを絞り込み" @@ -16002,6 +16005,9 @@ msgstr "不明な変数: {unknownVariables}" msgid "Unknown Welder" msgstr "正体不明の溶接工" +msgid "Unknown workspace" +msgstr "不明なワークスペース" + msgid "Unlimited" msgstr "無制限" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index 05f4ebf7a45..68790a691fd 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -6610,6 +6610,9 @@ msgstr "리뷰로 필터링" msgid "Filter by state" msgstr "상태로 필터링" +msgid "Filter by workspace: {label}" +msgstr "워크스페이스별 필터: {label}" + msgid "Filter hosts" msgstr "호스트 필터" @@ -16002,6 +16005,9 @@ msgstr "알 수 없는 변수: {unknownVariables}" msgid "Unknown Welder" msgstr "미지의 용접공" +msgid "Unknown workspace" +msgstr "알 수 없는 워크스페이스" + msgid "Unlimited" msgstr "무제한" diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index d90446dbaed..c2e405317ae 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -6610,6 +6610,9 @@ msgstr "Filteren op reviews" msgid "Filter by state" msgstr "Filteren op status" +msgid "Filter by workspace: {label}" +msgstr "Filteren op werkruimte: {label}" + msgid "Filter hosts" msgstr "Hosts filteren" @@ -16002,6 +16005,9 @@ msgstr "Onbekende variabelen: {unknownVariables}" msgid "Unknown Welder" msgstr "Onbekende Lasser" +msgid "Unknown workspace" +msgstr "Onbekende werkruimte" + msgid "Unlimited" msgstr "Onbeperkt" diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index 24964daab29..b6535e03ab1 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -6610,6 +6610,9 @@ msgstr "Filtruj wg przeglądów" msgid "Filter by state" msgstr "Filtruj wg stanu" +msgid "Filter by workspace: {label}" +msgstr "Filtruj według obszaru roboczego: {label}" + msgid "Filter hosts" msgstr "Filtruj hosty" @@ -16002,6 +16005,9 @@ msgstr "Nieznane zmienne: {unknownVariables}" msgid "Unknown Welder" msgstr "Nieznany Spawacz" +msgid "Unknown workspace" +msgstr "Nieznany obszar roboczy" + msgid "Unlimited" msgstr "Bez limitu" diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index 3099d29131e..2706cfceb41 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -6610,6 +6610,9 @@ msgstr "Filtrar por revisões" msgid "Filter by state" msgstr "Filtrar por estado" +msgid "Filter by workspace: {label}" +msgstr "Filtrar por área de trabalho: {label}" + msgid "Filter hosts" msgstr "Filtrar hosts" @@ -16002,6 +16005,9 @@ msgstr "Variáveis desconhecidas: {unknownVariables}" msgid "Unknown Welder" msgstr "Soldador Desconhecido" +msgid "Unknown workspace" +msgstr "Área de trabalho desconhecida" + msgid "Unlimited" msgstr "Ilimitado" diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index d6c4ee4c74b..d34a026e794 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -6610,6 +6610,9 @@ msgstr "Фильтр по ревью" msgid "Filter by state" msgstr "Фильтр по состоянию" +msgid "Filter by workspace: {label}" +msgstr "Фильтр по рабочей области: {label}" + msgid "Filter hosts" msgstr "Фильтр хостов" @@ -16002,6 +16005,9 @@ msgstr "Неизвестные переменные: {unknownVariables}" msgid "Unknown Welder" msgstr "Неизвестный сварщик" +msgid "Unknown workspace" +msgstr "Неизвестная рабочая область" + msgid "Unlimited" msgstr "Без ограничений" diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index a075ef4ea0b..cde20407bb2 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -6610,6 +6610,9 @@ msgstr "İncelemeye göre filtrele" msgid "Filter by state" msgstr "Duruma göre filtrele" +msgid "Filter by workspace: {label}" +msgstr "Çalışma alanına göre filtrele: {label}" + msgid "Filter hosts" msgstr "Host'ları filtrele" @@ -16002,6 +16005,9 @@ msgstr "Bilinmeyen değişkenler: {unknownVariables}" msgid "Unknown Welder" msgstr "Bilinmeyen Kaynakçı" +msgid "Unknown workspace" +msgstr "Bilinmeyen çalışma alanı" + msgid "Unlimited" msgstr "Sınırsız" diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 9c3d725cc5a..7dacbe029cc 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -6610,6 +6610,9 @@ msgstr "Lọc theo review" msgid "Filter by state" msgstr "Lọc theo trạng thái" +msgid "Filter by workspace: {label}" +msgstr "Lọc theo workspace: {label}" + msgid "Filter hosts" msgstr "Lọc host" @@ -16002,6 +16005,9 @@ msgstr "Biến không xác định: {unknownVariables}" msgid "Unknown Welder" msgstr "Thợ hàn vô danh" +msgid "Unknown workspace" +msgstr "Workspace không xác định" + msgid "Unlimited" msgstr "Không giới hạn" diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index 298181d5b6b..b7d01ad1646 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -6610,6 +6610,9 @@ msgstr "按审查筛选" msgid "Filter by state" msgstr "按状态筛选" +msgid "Filter by workspace: {label}" +msgstr "按工作区筛选:{label}" + msgid "Filter hosts" msgstr "筛选主机" @@ -16002,6 +16005,9 @@ msgstr "未知变量:{unknownVariables}" msgid "Unknown Welder" msgstr "未知焊工" +msgid "Unknown workspace" +msgstr "未知工作区" + msgid "Unlimited" msgstr "无限" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index 448d7f4eca0..8c9544decda 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -6610,6 +6610,9 @@ msgstr "按審查篩選" msgid "Filter by state" msgstr "按狀態篩選" +msgid "Filter by workspace: {label}" +msgstr "依工作區篩選:{label}" + msgid "Filter hosts" msgstr "篩選主機" @@ -16002,6 +16005,9 @@ msgstr "未知變數:{unknownVariables}" msgid "Unknown Welder" msgstr "未知焊接工" +msgid "Unknown workspace" +msgstr "未知工作區" + msgid "Unlimited" msgstr "無限" diff --git a/packages/trpc/src/router/page/page.ts b/packages/trpc/src/router/page/page.ts index 09cd018d52d..228034cc053 100644 --- a/packages/trpc/src/router/page/page.ts +++ b/packages/trpc/src/router/page/page.ts @@ -342,6 +342,32 @@ export const pageRouter = { ? { updatedAt: last.updatedAtCursor, id: last.id } : null; + const links = pageRows.length + ? await db + .select({ + pageId: workspacePages.pageId, + workspaceId: workspacePages.workspaceId, + entryPath: workspacePages.entryPath, + }) + .from(workspacePages) + .where( + inArray( + workspacePages.pageId, + pageRows.map((row) => row.id), + ), + ) + : []; + + const linksByPage = new Map< + string, + { workspaceId: string; entryPath: string }[] + >(); + for (const link of links) { + const list = linksByPage.get(link.pageId) ?? []; + list.push({ workspaceId: link.workspaceId, entryPath: link.entryPath }); + linksByPage.set(link.pageId, list); + } + const baseUrl = env.USERCONTENT_URL; const items = await Promise.all( pageRows.map(async ({ updatedAtCursor: _cursor, ...row }) => { @@ -355,6 +381,7 @@ export const pageRouter = { : await mintPageTicket(row, { version: served }); return { ...row, + workspaceLinks: linksByPage.get(row.id) ?? [], url: pageUrl(row.slug), viewUrl: pageViewUrl({ baseUrl, pageId: row.id, ticket }), thumbnailUrl: From bd7a80b44ef24c66a24b55f36dd312c8108807c4 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 10:40:06 +0530 Subject: [PATCH 08/16] fix(pages): name cloud workspaces in the pages list too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listWorkspacesOnHost` is single-host by design — "no org-wide fan-out, the desktop is the cross-host view" — so a page published from a cloud sandbox had no name to resolve against and rendered as a short id. The desktop side does not have this gap: with no device filter it reads the fan-out source across every known host, and a sandbox registers as one. Cloud names come from cloudWorkspace.list, which is where a cloud workspace's name actually lives: `cloudWorkspace.rename` writes that row, and the desktop routes a sandbox rename to it rather than to the host, because "renaming through the host would write to a name nothing reads". So cloud is applied last and wins for the ids it knows, which are exactly the cloud workspaces; the host keeps naming its own. Reaching the sandbox's host-service for the name would be authoritative-looking but wrong as well as expensive — the row in there is vestigial, and a cold sandbox would have to be woken to read it. Each lookup fails soft and they run concurrently: an account without cloud access, or a machine with no host running, still gets the other half. Note: the `cloud_workspaces.name` schema comment says the opposite ("a rename lands on the sandbox and leaves these behind"). It predates renames moving to the API and is now misleading; left alone here as it is outside this change. --- .../cli/src/commands/pages/list/command.ts | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/pages/list/command.ts b/packages/cli/src/commands/pages/list/command.ts index 9f3ab9c36de..181dbbcf31f 100644 --- a/packages/cli/src/commands/pages/list/command.ts +++ b/packages/cli/src/commands/pages/list/command.ts @@ -18,23 +18,46 @@ interface WorkspaceLink { type PageRow = Record & { workspaceLinks?: WorkspaceLink[] }; -async function workspaceNames(ctx: CliContext): Promise> { - const organizationId = ctx.config.organizationId; - if (!organizationId) return new Map(); +async function cloudWorkspaceNames( + ctx: CliContext, + organizationId: string, +): Promise<[string, string][]> { + try { + const workspaces = await ctx.api.cloudWorkspace.list.query({ + organizationId, + }); + return workspaces.map((workspace) => [workspace.id, workspace.name]); + } catch { + return []; + } +} + +async function hostWorkspaceNames( + ctx: CliContext, + organizationId: string, +): Promise<[string, string][]> { try { const { workspaces } = await listWorkspacesOnHost({ organizationId, userJwt: ctx.bearer, api: ctx.api, }); - return new Map( - workspaces.map((workspace) => [workspace.id, workspace.name]), - ); + return workspaces.map((workspace) => [workspace.id, workspace.name]); } catch { - return new Map(); + return []; } } +async function workspaceNames(ctx: CliContext): Promise> { + const organizationId = ctx.config.organizationId; + if (!organizationId) return new Map(); + const [cloud, host] = await Promise.all([ + cloudWorkspaceNames(ctx, organizationId), + hostWorkspaceNames(ctx, organizationId), + ]); + return new Map([...host, ...cloud]); +} + export function nameLinks( rows: PageRow[], names: Map, From 665399887e3322db2a46cb038542228bbfa42f05 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Tue, 22 Sep 2026 13:01:11 +0530 Subject: [PATCH 09/16] fix(pages): pick a named workspace link, and show the filter at one option Two review findings on the previous commit, both real. `page.list` returned a page's workspace links unordered, so `workspaceCell` taking `links[0]` could show a short id while a later link carried a resolved name. The links are now ordered by workspace id, and the cell prefers a named link over position. The desktop filter copied `AuthorFilter`'s "more than one option" guard, which does not transfer: every page has an author, so one author cannot narrow the list, but a page may have no workspace link at all, so one workspace option still separates linked pages from unlinked ones. --- .../pages/components/PagesView/PagesView.tsx | 2 +- .../cli/src/commands/pages/list/command.test.ts | 14 ++++++++++++++ packages/cli/src/commands/pages/list/command.ts | 6 +++--- packages/trpc/src/router/page/page.ts | 1 + 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx index 16743435b55..5f1b8a6513b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx @@ -238,7 +238,7 @@ export function PagesView({
- {(workspaceOptions.length > 1 || workspaceId !== null) && ( + {(workspaceOptions.length > 0 || workspaceId !== null) && ( { ).toBe("pipeline-review +1"); }); + test("shows a name from a later link when the first is unresolved", () => { + expect( + workspaceCell( + named( + [ + { workspaceId: WORKSPACE_B, entryPath: "b.html" }, + { workspaceId: WORKSPACE_A, entryPath: "a.html" }, + ], + new Map([[WORKSPACE_A, "pipeline-review"]]), + ), + ), + ).toBe("pipeline-review +1"); + }); + test("marks a page that belongs to no workspace", () => { expect(workspaceCell(row([]))).toBe("—"); expect(workspaceCell(row())).toBe("—"); diff --git a/packages/cli/src/commands/pages/list/command.ts b/packages/cli/src/commands/pages/list/command.ts index 181dbbcf31f..d1495ea542d 100644 --- a/packages/cli/src/commands/pages/list/command.ts +++ b/packages/cli/src/commands/pages/list/command.ts @@ -78,9 +78,9 @@ export function nameLinks( export function workspaceCell(row: PageRow): string { const links = row.workspaceLinks ?? []; - const [first] = links; - if (!first) return "—"; - const label = first.name ?? first.workspaceId.slice(0, 8); + const shown = links.find((link) => link.name) ?? links[0]; + if (!shown) return "—"; + const label = shown.name ?? shown.workspaceId.slice(0, 8); return links.length > 1 ? `${label} +${links.length - 1}` : label; } diff --git a/packages/trpc/src/router/page/page.ts b/packages/trpc/src/router/page/page.ts index 228034cc053..05c45b54360 100644 --- a/packages/trpc/src/router/page/page.ts +++ b/packages/trpc/src/router/page/page.ts @@ -356,6 +356,7 @@ export const pageRouter = { pageRows.map((row) => row.id), ), ) + .orderBy(workspacePages.workspaceId) : []; const linksByPage = new Map< From cace2e8f51dc266384794e1f7c3f0871d5b608c6 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Wed, 23 Sep 2026 06:29:43 +0530 Subject: [PATCH 10/16] refactor(pages): move pagesListInput beside the other dashboard utils pagesListInput is a pure function, not a hook, and three call sites across pages/ and v2-workspace/ reach for it. _dashboard/utils/ is the highest shared parent, which is where the repo puts a util used more than once. --- .../_dashboard/hooks/useAllPages/index.ts | 6 +----- .../_dashboard/hooks/useAllPages/useAllPages.ts | 15 ++++----------- .../pages/components/PagesView/PagesView.tsx | 6 ++---- .../_dashboard/utils/pagesListInput/index.ts | 1 + .../utils/pagesListInput/pagesListInput.ts | 10 ++++++++++ .../WorkspacePagesMenu/WorkspacePagesMenu.tsx | 6 ++---- 6 files changed, 20 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/index.ts index fa09bb59d5e..ef3dccd1fb8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/index.ts @@ -1,5 +1 @@ -export { - type PagesListFilter, - pagesListInput, - useAllPages, -} from "./useAllPages"; +export { useAllPages } from "./useAllPages"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/useAllPages.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/useAllPages.ts index 0e2004d78c1..08ea77f19f8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/useAllPages.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/useAllPages.ts @@ -1,16 +1,9 @@ import { useEffect, useMemo } from "react"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; - -const PAGES_PER_BATCH = 200; - -export interface PagesListFilter { - workspaceId?: string; - search?: string; -} - -export function pagesListInput(filter: PagesListFilter = {}) { - return { limit: PAGES_PER_BATCH, ...filter }; -} +import { + type PagesListFilter, + pagesListInput, +} from "renderer/routes/_authenticated/_dashboard/utils/pagesListInput"; export function useAllPages( filter: PagesListFilter = {}, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx index 5f1b8a6513b..9ae6a04c2d6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx @@ -8,15 +8,13 @@ import { LuSearch } from "react-icons/lu"; import { authClient } from "renderer/lib/auth-client"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; import { FeatureHeader } from "renderer/routes/_authenticated/_dashboard/components/FeatureHeader"; -import { - pagesListInput, - useAllPages, -} from "renderer/routes/_authenticated/_dashboard/hooks/useAllPages"; +import { useAllPages } from "renderer/routes/_authenticated/_dashboard/hooks/useAllPages"; import { isPaneModifier, useOpenPage, } from "renderer/routes/_authenticated/_dashboard/hooks/useOpenPage"; import { usePageFavorites } from "renderer/routes/_authenticated/_dashboard/hooks/usePageFavorites"; +import { pagesListInput } from "renderer/routes/_authenticated/_dashboard/utils/pagesListInput"; import { useAccessibleV2Workspaces } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; import { filterPages, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/index.ts new file mode 100644 index 00000000000..5a001db5f20 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/index.ts @@ -0,0 +1 @@ +export { type PagesListFilter, pagesListInput } from "./pagesListInput"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts new file mode 100644 index 00000000000..e48cd759817 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts @@ -0,0 +1,10 @@ +const PAGES_PER_BATCH = 200; + +export interface PagesListFilter { + workspaceId?: string; + search?: string; +} + +export function pagesListInput(filter: PagesListFilter = {}) { + return { limit: PAGES_PER_BATCH, ...filter }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx index 8173f67e563..42d083e0aca 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx @@ -7,15 +7,13 @@ import { ArrowLeft, FileText, LayoutGrid, Plus } from "lucide-react"; import { type MouseEvent, useCallback, useMemo, useState } from "react"; import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import { - pagesListInput, - useAllPages, -} from "renderer/routes/_authenticated/_dashboard/hooks/useAllPages"; +import { useAllPages } from "renderer/routes/_authenticated/_dashboard/hooks/useAllPages"; import { isPaneModifier, useOpenPage, } from "renderer/routes/_authenticated/_dashboard/hooks/useOpenPage"; import { usePageFavorites } from "renderer/routes/_authenticated/_dashboard/hooks/usePageFavorites"; +import { pagesListInput } from "renderer/routes/_authenticated/_dashboard/utils/pagesListInput"; import type { CreateNewAgentSession } from "../../hooks/useAgentSessionLauncher"; import { NewPageComposer } from "./components/NewPageComposer"; import { PagesMenuRow } from "./components/PagesMenuRow"; From de9d56fa3ba766b9f6cbc541e1d5943f92abd204 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Wed, 23 Sep 2026 08:28:08 +0530 Subject: [PATCH 11/16] fix(pages): filter page.list on the server, and key it on created_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the review of #7726, all High. The list could page, but no client used it as one. useAllPages ran an effect that called fetchNextPage until hasNextPage went false, and mobile and the CLI each looped on the cursor, so opening Pages still downloaded every row in the organization and minted a ticket per row — just across N/200 requests instead of one. The workspace menu swept the whole org to find the viewer's pins. The keyset was also on updated_at, which setWatch bumps: pages.updatedAt carries $onUpdate, the watch heartbeat writes that row every 30s, and a bumped row jumps above an open cursor and pushes its neighbour out of the walk. A review reproduced it on PG17 at 462 of 463 pages. The drains above are what kept it invisible; loading on scroll would have shown it. So scope, author, workspace, search and an id set move into listPagesSchema and the WHERE clause, and the ordering moves to (created_at, id) with a matching index. Pins stay client-side — they live in renderer storage, not a column — so the pinned tab narrows by `ids`, bounded by the same 200 the pin cap already enforces. Tab counts, the workspace breakdown and the author list come from a new page.counts, since a paginated list can no longer count what it has not loaded. The desktop grid and the mobile list fetch a batch when the viewer reaches the end. The cursor becomes an opaque string encoded by the server. It had two public formats — a {updatedAt, id} object over tRPC and MCP, and the CLI's base64 of the same — which made the keyset part of the API and this change a breaking one. Clients now pass back what they were handed. Verified against a real Postgres 17.11 for the first time: list.integration.ts walks 463 rows and asserts the set is exact, bumps a watched page mid-walk and asserts nothing drops, and checks the filters and counts agree. The query plan is still unmeasured at size. Deliberate: pinned pages no longer float to the top of All — ordering pinned-first across batches would need the client's pin set in ORDER BY, which defeats the index. The Pinned tab covers it. --- .../_dashboard/hooks/useAllPages/index.ts | 1 - .../hooks/useAllPages/useAllPages.ts | 36 - .../_dashboard/hooks/usePagesList/index.ts | 1 + .../hooks/usePagesList/usePagesList.ts | 63 + .../components/PagesView/PagesView.test.tsx | 217 +- .../pages/components/PagesView/PagesView.tsx | 268 +- .../_authenticated/_dashboard/pages/page.tsx | 2 +- .../utils/filterPages/filterPages.test.ts | 172 - .../pages/utils/filterPages/filterPages.ts | 96 - .../pages/utils/filterPages/index.ts | 11 - .../_dashboard/pages/utils/pageScope/index.ts | 6 + .../pages/utils/pageScope/pageScope.test.ts | 28 + .../pages/utils/pageScope/pageScope.ts | 17 + .../utils/pagesListInput/pagesListInput.ts | 15 +- .../WorkspacePagesMenu/WorkspacePagesMenu.tsx | 24 +- .../(authenticated)/pages/PagesScreen.tsx | 20 +- .../pages/hooks/usePages/usePages.ts | 88 +- .../pages/stores/pagesFilterStore/index.ts | 1 - .../pagesFilterStore/pagesFilterStore.ts | 14 - .../WorkspacePages/WorkspacePages.tsx | 3 +- .../cli/src/commands/pages/list/command.ts | 15 +- .../cli/src/commands/pages/pageList.test.ts | 46 - packages/cli/src/commands/pages/pageList.ts | 45 +- .../0120_pages_created_at_keyset_index.sql | 1 + packages/db/drizzle/meta/0120_snapshot.json | 11074 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/schema.ts | 7 + packages/i18n/locales/cs/messages.po | 9 +- packages/i18n/locales/de/messages.po | 9 +- packages/i18n/locales/en/messages.po | 9 +- packages/i18n/locales/es/messages.po | 9 +- packages/i18n/locales/fr/messages.po | 9 +- packages/i18n/locales/id/messages.po | 9 +- packages/i18n/locales/it/messages.po | 9 +- packages/i18n/locales/ja/messages.po | 9 +- packages/i18n/locales/ko/messages.po | 9 +- packages/i18n/locales/nl/messages.po | 9 +- packages/i18n/locales/pl/messages.po | 9 +- packages/i18n/locales/pt-BR/messages.po | 9 +- packages/i18n/locales/ru/messages.po | 9 +- packages/i18n/locales/tr/messages.po | 9 +- packages/i18n/locales/vi/messages.po | 9 +- packages/i18n/locales/zh-CN/messages.po | 9 +- packages/i18n/locales/zh-TW/messages.po | 9 +- packages/mcp/src/tools/pages/list.ts | 19 +- packages/trpc/src/router/page/cursor.test.ts | 77 + packages/trpc/src/router/page/cursor.ts | 41 + .../trpc/src/router/page/list.integration.ts | 264 + packages/trpc/src/router/page/page.ts | 157 +- packages/trpc/src/router/page/schema.test.ts | 55 +- packages/trpc/src/router/page/schema.ts | 57 +- 51 files changed, 12365 insertions(+), 736 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/useAllPages.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePagesList/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePagesList/usePagesList.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/pageScope.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/pageScope.ts delete mode 100644 packages/cli/src/commands/pages/pageList.test.ts create mode 100644 packages/db/drizzle/0120_pages_created_at_keyset_index.sql create mode 100644 packages/db/drizzle/meta/0120_snapshot.json create mode 100644 packages/trpc/src/router/page/cursor.test.ts create mode 100644 packages/trpc/src/router/page/cursor.ts create mode 100644 packages/trpc/src/router/page/list.integration.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/index.ts deleted file mode 100644 index ef3dccd1fb8..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useAllPages } from "./useAllPages"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/useAllPages.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/useAllPages.ts deleted file mode 100644 index 08ea77f19f8..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useAllPages/useAllPages.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import { - type PagesListFilter, - pagesListInput, -} from "renderer/routes/_authenticated/_dashboard/utils/pagesListInput"; - -export function useAllPages( - filter: PagesListFilter = {}, - options: { enabled?: boolean; staleTime?: number } = {}, -) { - const query = cloudTrpc.page.list.useInfiniteQuery(pagesListInput(filter), { - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - ...options, - }); - - const { - hasNextPage, - isFetchingNextPage, - isFetchNextPageError, - fetchNextPage, - } = query; - - useEffect(() => { - if (hasNextPage && !isFetchingNextPage && !isFetchNextPageError) { - void fetchNextPage(); - } - }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]); - - const items = useMemo( - () => query.data?.pages.flatMap((page) => page.items) ?? [], - [query.data], - ); - - return { ...query, items, isIncomplete: isFetchNextPageError }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePagesList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePagesList/index.ts new file mode 100644 index 00000000000..5c7c2b172a4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePagesList/index.ts @@ -0,0 +1 @@ +export { usePagesList } from "./usePagesList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePagesList/usePagesList.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePagesList/usePagesList.ts new file mode 100644 index 00000000000..37caeb84f89 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePagesList/usePagesList.ts @@ -0,0 +1,63 @@ +import { useEffect, useMemo, useRef } from "react"; +import { cloudTrpc } from "renderer/lib/cloud-trpc"; +import { + type PagesListFilter, + pagesListInput, +} from "renderer/routes/_authenticated/_dashboard/utils/pagesListInput"; + +/** + * One batch per scroll. Attach `scrollRef` to the scroll container and + * `sentinelRef` to an element after the last row; nothing fetches ahead of + * what the viewer has reached. + */ +export function usePagesList( + filter: PagesListFilter = {}, + options: { enabled?: boolean; staleTime?: number } = {}, +) { + const query = cloudTrpc.page.list.useInfiniteQuery(pagesListInput(filter), { + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + ...options, + }); + + const { + hasNextPage, + isFetchingNextPage, + isFetchNextPageError, + fetchNextPage, + } = query; + + const scrollRef = useRef(null); + const sentinelRef = useRef(null); + + useEffect(() => { + const sentinel = sentinelRef.current; + const root = scrollRef.current; + // React Query derives `hasNextPage` from the last successful fetch, so a + // failed batch leaves it true — observing again would spin. The caller + // offers a retry instead. + if ( + !sentinel || + !root || + !hasNextPage || + isFetchingNextPage || + isFetchNextPageError + ) { + return; + } + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) void fetchNextPage(); + }, + { root, rootMargin: "200px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage]); + + const items = useMemo( + () => query.data?.pages.flatMap((page) => page.items) ?? [], + [query.data], + ); + + return { ...query, items, scrollRef, sentinelRef }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx index 5b49e5987b5..826ec531e33 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx @@ -24,7 +24,7 @@ interface ListPage { slug: string; visibility: string; }>; - nextCursor: { updatedAt: string; id: string } | null; + nextCursor: string | null; } interface InfiniteResult { @@ -40,6 +40,35 @@ interface InfiniteResult { const fetchNextPage = mock(() => {}); let listResult: InfiniteResult; +let listInput: Record | undefined; +let countsInput: Record | undefined; +let countsData: { + all: number; + team: number; + mine: number; + pinned: number; + workspaces: Array<{ workspaceId: string; count: number }>; + authors: Array<{ + userId: string | null; + name: string | null; + image: string | null; + count: number; + }>; +}; + +/** Lets a test fire the sentinel without a real viewport. */ +const observed: Array<() => void> = []; +class TestIntersectionObserver { + constructor( + private callback: (entries: Array<{ isIntersecting: boolean }>) => void, + ) {} + observe() { + observed.push(() => this.callback([{ isIntersecting: true }])); + } + disconnect() {} +} +(globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = + TestIntersectionObserver; function page(id: string, visibility = "org") { return { id, title: `Page ${id}`, slug: `page-${id}`, visibility }; @@ -48,7 +77,18 @@ function page(id: string, visibility = "org") { mock.module("renderer/lib/cloud-trpc", () => ({ cloudTrpc: { page: { - list: { useInfiniteQuery: () => listResult }, + list: { + useInfiniteQuery: (input: Record) => { + listInput = input; + return listResult; + }, + }, + counts: { + useQuery: (input: Record) => { + countsInput = input; + return { data: countsData }; + }, + }, delete: { useMutation: () => ({ mutateAsync: mock() }) }, }, useUtils: () => ({ @@ -59,6 +99,7 @@ mock.module("renderer/lib/cloud-trpc", () => ({ setInfiniteData: mock(), invalidate: mock(), }, + counts: { invalidate: mock() }, }, }), }, @@ -87,11 +128,13 @@ mock.module("./hooks/useCreatePageWithAgent", () => ({ }), })); +const PINS = ["pin-1", "pin-2"]; mock.module( "renderer/routes/_authenticated/_dashboard/hooks/usePageFavorites", () => ({ usePageFavorites: () => ({ - favoritePageIdSet: new Set(), + favoritePageIds: PINS, + favoritePageIdSet: new Set(PINS), toggleFavorite: mock(), }), }), @@ -108,20 +151,27 @@ mock.module( mock.module( "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces", () => ({ - useAccessibleV2Workspaces: () => ({ all: [] }), + useAccessibleV2Workspaces: () => ({ + all: [{ id: "ws-1", name: "Workspace One" }], + }), }), ); const { act, cleanup, render } = await import("@testing-library/react"); const { PagesView } = await import("./PagesView"); -function renderView(scope: "all" | "pinned" = "all") { +function renderView({ + scope = "all" as "all" | "pinned" | "team" | "mine", + search = "", + authorId = null as string | null, + workspaceId = null as string | null, +} = {}) { return render( { fetchNextPage.mockClear(); onScopeChange.mockClear(); + observed.length = 0; + listInput = undefined; + countsInput = undefined; + countsData = { + all: 1, + team: 1, + mine: 0, + pinned: 2, + workspaces: [{ workspaceId: "ws-1", count: 3 }], + authors: [ + { userId: "user-1", name: "Ada", image: null, count: 2 }, + { userId: "user-2", name: "Grace", image: null, count: 1 }, + ], + }; listResult = { data: { pages: [{ items: [page("a")], nextCursor: null }] }, error: null, @@ -149,32 +213,128 @@ afterAll(async () => { if (!alreadyRegistered) await GlobalRegistrator.unregister(); }); -describe("PagesView drain", () => { - test("keeps pulling batches while the server reports more", async () => { +describe("PagesView server-side filtering", () => { + test("sends the search to the server instead of filtering what it loaded", async () => { + await act(async () => { + renderView({ search: "report" }); + }); + expect(listInput).toMatchObject({ search: "report" }); + }); + + test("sends the scope, the author and the workspace too", async () => { + await act(async () => { + renderView({ scope: "team", authorId: "user-1", workspaceId: "ws-1" }); + }); + expect(listInput).toMatchObject({ + scope: "team", + authorId: "user-1", + workspaceId: "ws-1", + }); + }); + + test("asks for the pinned tab by id, because pins are not a server column", async () => { + await act(async () => { + renderView({ scope: "pinned" }); + }); + expect(listInput).toMatchObject({ scope: "all", ids: PINS }); + }); + + test("asks for one batch, not the whole organization", async () => { + await act(async () => { + renderView(); + }); + expect(listInput?.limit).toBe(50); + }); +}); + +describe("PagesView loads on scroll", () => { + test("does not sweep the organization on mount", async () => { + listResult.hasNextPage = true; + await act(async () => { + renderView(); + }); + expect(fetchNextPage).not.toHaveBeenCalled(); + }); + + test("fetches the next batch when the sentinel comes into view", async () => { listResult.hasNextPage = true; await act(async () => { renderView(); }); + expect(observed.length).toBeGreaterThan(0); + await act(async () => { + for (const fire of observed) fire(); + }); expect(fetchNextPage).toHaveBeenCalled(); }); - test("does not re-arm while a batch is already in flight", async () => { + test("does not observe while a batch is already in flight", async () => { listResult.hasNextPage = true; listResult.isFetchingNextPage = true; await act(async () => { renderView(); }); - expect(fetchNextPage).not.toHaveBeenCalled(); + expect(observed).toHaveLength(0); }); - test("stops after a failed batch instead of looping forever", async () => { + test("does not re-observe after a failed batch, which would spin", async () => { listResult.hasNextPage = true; listResult.isFetchNextPageError = true; - listResult.error = { message: "network down" }; await act(async () => { renderView(); }); - expect(fetchNextPage).not.toHaveBeenCalled(); + expect(observed).toHaveLength(0); + }); +}); + +describe("PagesView counts", () => { + test("takes the tab counts from the server, not from what it loaded", async () => { + countsData.all = 42; + const view = await act(async () => renderView()); + const allTab = view.getByRole("tab", { name: /All/ }); + // One loaded page, 42 in the organization: the count is the server's. + expect(allTab.textContent).toContain("42"); + expect(gridProps.pages).toHaveLength(1); + expect(countsInput).toMatchObject({ pinnedIds: PINS }); + }); + + test("offers the workspace filter off the server breakdown", async () => { + const view = await act(async () => renderView()); + expect( + view.queryByRole("button", { name: /Filter by workspace/ }), + ).toBeTruthy(); + }); + + test("hides the workspace filter when no workspace has pages", async () => { + countsData.workspaces = []; + const view = await act(async () => renderView()); + expect( + view.queryByRole("button", { name: /Filter by workspace/ }), + ).toBeNull(); + }); + + test("does not narrow the counts by the scope being counted", async () => { + await act(async () => { + renderView({ scope: "mine" }); + }); + expect(countsInput).not.toHaveProperty("scope"); + }); +}); + +describe("PagesView empty state", () => { + test("does not blank a loaded grid while the counts are still in flight", async () => { + countsData = undefined as never; + const view = await act(async () => renderView()); + expect(gridProps.pages).toHaveLength(1); + // The filter bar is hidden only when the organization really has no pages. + expect(view.queryByRole("tab", { name: /All/ })).toBeTruthy(); + }); + + test("hides the filters when the organization has no pages at all", async () => { + listResult.data = { pages: [{ items: [], nextCursor: null }] }; + countsData.all = 0; + const view = await act(async () => renderView()); + expect(view.queryByRole("tab", { name: /All/ })).toBeNull(); }); }); @@ -190,13 +350,12 @@ describe("PagesView error surfacing", () => { expect(gridProps.pages).toHaveLength(1); }); - test("says the list is short, and retries the batch on demand", async () => { + test("retries the failed batch on demand", async () => { listResult.hasNextPage = true; listResult.isFetchNextPageError = true; listResult.error = { message: "network down" }; const view = await act(async () => renderView()); const retry = view.getByRole("button", { name: "Retry" }); - expect(retry).toBeTruthy(); fetchNextPage.mockClear(); await act(async () => { retry.click(); @@ -204,7 +363,7 @@ describe("PagesView error surfacing", () => { expect(fetchNextPage).toHaveBeenCalled(); }); - test("stays quiet while the sweep is still going fine", async () => { + test("stays quiet while paging is going fine", async () => { listResult.hasNextPage = true; const view = await act(async () => renderView()); expect(view.queryByRole("button", { name: "Retry" })).toBeNull(); @@ -221,27 +380,17 @@ describe("PagesView error surfacing", () => { }); describe("PagesView pinned tab", () => { - test("stays put while later batches could still contain pinned pages", async () => { - listResult.hasNextPage = true; + test("stays on the pinned tab — the server already answered how many", async () => { await act(async () => { - renderView("pinned"); + renderView({ scope: "pinned" }); }); expect(onScopeChange).not.toHaveBeenCalled(); }); - test("stays put on a cold mount, before the first batch has landed", async () => { - listResult.data = undefined; - listResult.isPending = true; - await act(async () => { - renderView("pinned"); - }); + test("keeps the tab visible when the viewer is sitting on it with nothing pinned", async () => { + countsData.pinned = 0; + const view = await act(async () => renderView({ scope: "pinned" })); + expect(view.queryByText("Pinned")).toBeTruthy(); expect(onScopeChange).not.toHaveBeenCalled(); }); - - test("falls back to all once the sweep finished with nothing pinned", async () => { - await act(async () => { - renderView("pinned"); - }); - expect(onScopeChange).toHaveBeenCalledWith("all"); - }); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx index 9ae6a04c2d6..ee3c99d20d3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx @@ -3,25 +3,21 @@ import { COMPANY } from "@superset/shared/constants"; import { Input } from "@superset/ui/input"; import { toast } from "@superset/ui/sonner"; import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; -import { useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { LuSearch } from "react-icons/lu"; import { authClient } from "renderer/lib/auth-client"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; import { FeatureHeader } from "renderer/routes/_authenticated/_dashboard/components/FeatureHeader"; -import { useAllPages } from "renderer/routes/_authenticated/_dashboard/hooks/useAllPages"; +import { useDebouncedSearchNavigation } from "renderer/routes/_authenticated/_dashboard/hooks/useDebouncedSearchNavigation"; import { isPaneModifier, useOpenPage, } from "renderer/routes/_authenticated/_dashboard/hooks/useOpenPage"; import { usePageFavorites } from "renderer/routes/_authenticated/_dashboard/hooks/usePageFavorites"; +import { usePagesList } from "renderer/routes/_authenticated/_dashboard/hooks/usePagesList"; import { pagesListInput } from "renderer/routes/_authenticated/_dashboard/utils/pagesListInput"; import { useAccessibleV2Workspaces } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; -import { - filterPages, - matchesScope, - type PageScope, - sortPinnedFirst, -} from "../../utils/filterPages"; +import { type PageScope, serverScope } from "../../utils/pageScope"; import { PagesGrid } from "../PagesGrid"; import { AuthorFilter, type PageAuthorOption } from "./components/AuthorFilter"; import { @@ -30,8 +26,6 @@ import { } from "./components/WorkspaceFilter"; import { useCreatePageWithAgent } from "./hooks/useCreatePageWithAgent"; -const PAGES_QUERY = pagesListInput(); - const TABS: Array<{ value: PageScope }> = [ { value: "all" }, { value: "pinned" }, @@ -64,14 +58,66 @@ export function PagesView({ const { creatingWithAgent, handleCreateWithAgent } = useCreatePageWithAgent(); const { data: session } = authClient.useSession(); const utils = cloudTrpc.useUtils(); - const pages = useAllPages(); - const { hasNextPage, isFetchNextPageError, fetchNextPage } = pages; + const { favoritePageIds, favoritePageIdSet, toggleFavorite } = + usePageFavorites(); + const openPage = useOpenPage(); + + // The input answers the keystroke; the URL — and so the query — settles. + const [searchInput, setSearchInput] = useState(search); + useEffect(() => setSearchInput(search), [search]); + const { scheduleSearchNavigation, cancelPendingSearchNavigation } = + useDebouncedSearchNavigation(onSearchChange); + const handleSearchChange = useCallback( + (value: string) => { + setSearchInput(value); + scheduleSearchNavigation(value); + }, + [scheduleSearchNavigation], + ); + + const filter = useMemo( + () => ({ + ...(search ? { search } : {}), + scope: serverScope(scope), + ...(authorId ? { authorId } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(scope === "pinned" ? { ids: favoritePageIds } : {}), + }), + [search, scope, authorId, workspaceId, favoritePageIds], + ); + + const pages = usePagesList(filter); + const { + items, + isFetchingNextPage, + isFetchNextPageError, + fetchNextPage, + scrollRef, + sentinelRef, + } = pages; + + const countsQuery = cloudTrpc.page.counts.useQuery({ + ...(search ? { search } : {}), + ...(authorId ? { authorId } : {}), + ...(workspaceId ? { workspaceId } : {}), + pinnedIds: favoritePageIds, + }); + const counts = useMemo( + () => ({ + all: countsQuery.data?.all ?? 0, + pinned: countsQuery.data?.pinned ?? 0, + team: countsQuery.data?.team ?? 0, + mine: countsQuery.data?.mine ?? 0, + }), + [countsQuery.data], + ); + const listInput = useMemo(() => pagesListInput(filter), [filter]); const deletePage = cloudTrpc.page.delete.useMutation({ onMutate: async ({ id }) => { - await utils.page.list.cancel(PAGES_QUERY); - const previous = utils.page.list.getInfiniteData(PAGES_QUERY); - utils.page.list.setInfiniteData(PAGES_QUERY, (old) => + await utils.page.list.cancel(listInput); + const previous = utils.page.list.getInfiniteData(listInput); + utils.page.list.setInfiniteData(listInput, (old) => old ? { ...old, @@ -86,15 +132,14 @@ export function PagesView({ }, onError: (_error, _variables, context) => { if (context?.previous) { - utils.page.list.setInfiniteData(PAGES_QUERY, context.previous); + utils.page.list.setInfiniteData(listInput, context.previous); } }, onSettled: () => { - void utils.page.list.invalidate(PAGES_QUERY); + void utils.page.list.invalidate(listInput); + void utils.page.counts.invalidate(); }, }); - const { favoritePageIdSet, toggleFavorite } = usePageFavorites(); - const openPage = useOpenPage(); const tabLabels: Record = { all: t({ message: "All" }), @@ -103,64 +148,43 @@ export function PagesView({ mine: t({ message: "Just me" }), }; - const all = pages.items; - const currentUserId = session?.user.id; const authorOptions = useMemo(() => { - const byAuthor = new Map(); - for (const page of all) { - if (!page.createdByUserId || byAuthor.has(page.createdByUserId)) { - continue; - } - byAuthor.set(page.createdByUserId, { - userId: page.createdByUserId, - name: - page.ownerName || - t({ - message: "Unknown", - }), - image: page.ownerImage, - isCurrentUser: page.createdByUserId === currentUserId, + const rows = countsQuery.data?.authors ?? []; + return rows + .flatMap((row) => + row.userId + ? [ + { + userId: row.userId, + name: row.name || t({ message: "Unknown" }), + image: row.image, + isCurrentUser: row.userId === currentUserId, + }, + ] + : [], + ) + .sort((a, b) => { + if (a.isCurrentUser !== b.isCurrentUser) + return a.isCurrentUser ? -1 : 1; + return a.name.localeCompare(b.name); }); - } - return Array.from(byAuthor.values()).sort((a, b) => { - if (a.isCurrentUser !== b.isCurrentUser) return a.isCurrentUser ? -1 : 1; - return a.name.localeCompare(b.name); - }); - }, [all, currentUserId, t]); + }, [countsQuery.data, currentUserId, t]); const { all: accessibleWorkspaces } = useAccessibleV2Workspaces(); const workspaceOptions = useMemo(() => { const names = new Map( accessibleWorkspaces.map((workspace) => [workspace.id, workspace.name]), ); - const counts = new Map(); - for (const page of all) { - for (const link of page.workspaceLinks ?? []) { - counts.set(link.workspaceId, (counts.get(link.workspaceId) ?? 0) + 1); - } - } - return Array.from(counts.entries()) - .filter(([id]) => names.has(id)) - .map(([id, count]) => ({ - workspaceId: id, - name: names.get(id) ?? id, - count, + return (countsQuery.data?.workspaces ?? []) + .filter((row) => names.has(row.workspaceId)) + .map((row) => ({ + workspaceId: row.workspaceId, + name: names.get(row.workspaceId) ?? row.workspaceId, + count: row.count, })) .sort((a, b) => a.name.localeCompare(b.name)); - }, [all, accessibleWorkspaces]); - - const counts = useMemo( - () => ({ - all: all.length, - pinned: all.filter((page) => favoritePageIdSet.has(page.id)).length, - team: all.filter((page) => matchesScope(page, "team", favoritePageIdSet)) - .length, - mine: all.filter((page) => matchesScope(page, "mine", favoritePageIdSet)) - .length, - }), - [all, favoritePageIdSet], - ); + }, [countsQuery.data, accessibleWorkspaces]); const tabs = useMemo( () => @@ -171,39 +195,29 @@ export function PagesView({ [counts.pinned, scope], ); - const pinnedEmpty = - pages.data !== undefined && - !hasNextPage && - scope === "pinned" && - counts.pinned === 0; - const activeScope = pinnedEmpty ? "all" : scope; - - useEffect(() => { - if (pinnedEmpty) onScopeChange("all"); - }, [pinnedEmpty, onScopeChange]); - - const visible = useMemo( - () => - sortPinnedFirst( - filterPages(all, { - search, - scope: activeScope, - pinnedPageIds: favoritePageIdSet, - authorId, - workspaceId, - }), - favoritePageIdSet, - ), - [all, search, activeScope, favoritePageIdSet, authorId, workspaceId], + const changeScope = useCallback( + (next: PageScope) => { + cancelPendingSearchNavigation(); + onScopeChange(next); + }, + [cancelPendingSearchNavigation, onScopeChange], ); - const orgEmpty = !pages.isPending && !pages.error && all.length === 0; + const hasFilters = + Boolean(search.trim()) || + scope !== "all" || + authorId !== null || + workspaceId !== null; + // From the list, not the counts: those arrive on their own query, and a + // zero default while they are in flight would blank a loaded grid. + const orgEmpty = + !hasFilters && !pages.isPending && !pages.error && items.length === 0; return (
-
+
Pages} @@ -216,8 +230,8 @@ export function PagesView({ {!orgEmpty && (
onScopeChange(value as PageScope)} + value={scope} + onValueChange={(value) => changeScope(value as PageScope)} > {tabs.map((tab) => ( @@ -240,21 +254,27 @@ export function PagesView({ { + cancelPendingSearchNavigation(); + onWorkspaceChange(value); + }} /> )} {(authorOptions.length > 1 || authorId !== null) && ( { + cancelPendingSearchNavigation(); + onAuthorChange(value); + }} /> )}
onSearchChange(event.target.value)} + value={searchInput} + onChange={(event) => handleSearchChange(event.target.value)} placeholder={t({ message: "Search pages", })} @@ -265,38 +285,15 @@ export function PagesView({
)} - {isFetchNextPageError && all.length > 0 && ( -
- - - Some pages couldn't load, so this list is incomplete. - - - -
- )} - openPage( page, @@ -313,6 +310,29 @@ export function PagesView({ ); }} /> + +
+ + {isFetchingNextPage && ( +

+ Loading more pages… +

+ )} + + {isFetchNextPageError && ( +
+ + Could not load more pages. + + +
+ )}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/page.tsx index b23811706f8..141fda910c5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/page.tsx @@ -3,7 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useFeatureFlagEnabled } from "posthog-js/react"; import { Redirect } from "renderer/components/Redirect"; import { PagesView } from "./components/PagesView"; -import { isPageScope, type PageScope } from "./utils/filterPages"; +import { isPageScope, type PageScope } from "./utils/pageScope"; export type PagesSearch = { q?: string; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts deleted file mode 100644 index 42fd6fa8ee5..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { - filterPages, - isPageScope, - matchesAuthor, - matchesScope, - matchesSearch, - matchesWorkspace, - sortPinnedFirst, -} from "./filterPages"; - -const page = ( - id: string, - title: string, - visibility: string, - description: string | null = null, - createdByUserId: string | null = null, -) => ({ - id, - title, - slug: title.toLowerCase().replace(/ /g, "-"), - visibility, - description, - createdByUserId, -}); - -const team = page("1", "Q3 Metrics", "org", null, "user-a"); -const mine = page("2", "Ingest Runbook", "just_me", "hookdeck notes", "user-b"); -const other = page("3", "Warning Tokens", "org"); -const published = page("4", "Launch Notes", "everyone", null, "user-a"); - -describe("isPageScope", () => { - it("accepts known scopes and rejects anything else", () => { - expect(isPageScope("all")).toBe(true); - expect(isPageScope("pinned")).toBe(true); - expect(isPageScope("nope")).toBe(false); - expect(isPageScope(undefined)).toBe(false); - }); -}); - -describe("matchesSearch", () => { - it("matches on title, slug, and description, case-insensitively", () => { - expect(matchesSearch(team, "q3")).toBe(true); - expect(matchesSearch(team, "q3-metrics")).toBe(true); - expect(matchesSearch(mine, "HOOKDECK")).toBe(true); - expect(matchesSearch(team, "hookdeck")).toBe(false); - }); - - it("treats blank and whitespace-only queries as no filter", () => { - expect(matchesSearch(team, "")).toBe(true); - expect(matchesSearch(team, " ")).toBe(true); - }); -}); - -describe("matchesScope", () => { - const pinned = new Set(["2"]); - - it("splits team and just-me by visibility", () => { - expect(matchesScope(team, "team", pinned)).toBe(true); - expect(matchesScope(mine, "team", pinned)).toBe(false); - expect(matchesScope(mine, "mine", pinned)).toBe(true); - expect(matchesScope(team, "mine", pinned)).toBe(false); - }); - - it("keeps a page shared with everyone in the team scope", () => { - expect(matchesScope(published, "team", pinned)).toBe(true); - expect(matchesScope(published, "mine", pinned)).toBe(false); - }); - - it("uses the pinned set rather than visibility for the pinned scope", () => { - expect(matchesScope(mine, "pinned", pinned)).toBe(true); - expect(matchesScope(team, "pinned", pinned)).toBe(false); - }); - - it("passes everything through for all", () => { - expect(matchesScope(team, "all", pinned)).toBe(true); - expect(matchesScope(mine, "all", new Set())).toBe(true); - }); -}); - -describe("matchesAuthor", () => { - it("passes everything through when no author is selected", () => { - expect(matchesAuthor(team, null)).toBe(true); - expect(matchesAuthor(other, null)).toBe(true); - }); - - it("matches only the selected author's pages", () => { - expect(matchesAuthor(team, "user-a")).toBe(true); - expect(matchesAuthor(mine, "user-a")).toBe(false); - }); - - it("excludes pages without a creator when an author is selected", () => { - expect(matchesAuthor(other, "user-a")).toBe(false); - }); -}); - -describe("filterPages", () => { - it("applies search and scope together", () => { - const result = filterPages([team, mine, other], { - search: "e", - scope: "team", - pinnedPageIds: new Set(), - }); - expect(result.map((p) => p.id)).toEqual(["1", "3"]); - }); - - it("returns nothing when the scope excludes every search hit", () => { - expect( - filterPages([team, mine], { - search: "hookdeck", - scope: "team", - pinnedPageIds: new Set(), - }), - ).toEqual([]); - }); - - it("applies the author filter alongside search and scope", () => { - const result = filterPages([team, mine, other], { - search: "", - scope: "all", - pinnedPageIds: new Set(), - authorId: "user-a", - }); - expect(result.map((p) => p.id)).toEqual(["1"]); - }); -}); - -describe("matchesWorkspace", () => { - const linked = { - ...team, - workspaceLinks: [{ workspaceId: "ws-a" }, { workspaceId: "ws-b" }], - }; - - it("keeps every page when no workspace is selected", () => { - expect(matchesWorkspace(other, null)).toBe(true); - }); - - it("matches a page linked to the selected workspace", () => { - expect(matchesWorkspace(linked, "ws-b")).toBe(true); - }); - - it("rejects a page linked only to other workspaces", () => { - expect(matchesWorkspace(linked, "ws-c")).toBe(false); - }); - - it("rejects a page with no workspace links once one is selected", () => { - expect(matchesWorkspace(other, "ws-a")).toBe(false); - }); - - it("filters alongside scope and author", () => { - const result = filterPages([linked, mine, other], { - search: "", - scope: "all", - pinnedPageIds: new Set(), - workspaceId: "ws-a", - }); - expect(result.map((p) => p.id)).toEqual(["1"]); - }); -}); - -describe("sortPinnedFirst", () => { - it("floats pinned pages while preserving relative order", () => { - const sorted = sortPinnedFirst([team, mine, other], new Set(["3"])); - expect(sorted.map((p) => p.id)).toEqual(["3", "1", "2"]); - }); - - it("does not mutate the input array", () => { - const input = [team, mine]; - sortPinnedFirst(input, new Set(["2"])); - expect(input.map((p) => p.id)).toEqual(["1", "2"]); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts deleted file mode 100644 index 0307641e1e5..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/filterPages.ts +++ /dev/null @@ -1,96 +0,0 @@ -export const PAGE_SCOPES = ["all", "pinned", "team", "mine"] as const; - -export type PageScope = (typeof PAGE_SCOPES)[number]; - -export function isPageScope(value: unknown): value is PageScope { - return (PAGE_SCOPES as readonly unknown[]).includes(value); -} - -export interface FilterablePage { - id: string; - title: string; - slug: string; - visibility: string; - description?: string | null; - createdByUserId?: string | null; - workspaceLinks?: readonly { workspaceId: string }[]; -} - -export function matchesSearch(page: FilterablePage, query: string): boolean { - const needle = query.trim().toLowerCase(); - if (!needle) return true; - if (page.title.toLowerCase().includes(needle)) return true; - if (page.slug.toLowerCase().includes(needle)) return true; - return Boolean(page.description?.toLowerCase().includes(needle)); -} - -export function matchesScope( - page: FilterablePage, - scope: PageScope, - pinnedPageIds: ReadonlySet, -): boolean { - switch (scope) { - case "pinned": - return pinnedPageIds.has(page.id); - case "team": - return page.visibility !== "just_me"; - case "mine": - return page.visibility === "just_me"; - default: - return true; - } -} - -export function matchesAuthor( - page: FilterablePage, - authorId: string | null, -): boolean { - if (!authorId) return true; - return page.createdByUserId === authorId; -} - -export function matchesWorkspace( - page: FilterablePage, - workspaceId: string | null, -): boolean { - if (!workspaceId) return true; - return Boolean( - page.workspaceLinks?.some((link) => link.workspaceId === workspaceId), - ); -} - -export function filterPages( - pages: T[], - { - search, - scope, - pinnedPageIds, - authorId = null, - workspaceId = null, - }: { - search: string; - scope: PageScope; - pinnedPageIds: ReadonlySet; - authorId?: string | null; - workspaceId?: string | null; - }, -): T[] { - return pages.filter( - (page) => - matchesSearch(page, search) && - matchesScope(page, scope, pinnedPageIds) && - matchesAuthor(page, authorId) && - matchesWorkspace(page, workspaceId), - ); -} - -export function sortPinnedFirst( - pages: T[], - pinnedPageIds: ReadonlySet, -): T[] { - return [...pages].sort((a, b) => { - const aPinned = pinnedPageIds.has(a.id) ? 0 : 1; - const bPinned = pinnedPageIds.has(b.id) ? 0 : 1; - return aPinned - bPinned; - }); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/index.ts deleted file mode 100644 index be55a469226..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/filterPages/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - type FilterablePage, - filterPages, - isPageScope, - matchesAuthor, - matchesScope, - matchesSearch, - PAGE_SCOPES, - type PageScope, - sortPinnedFirst, -} from "./filterPages"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/index.ts new file mode 100644 index 00000000000..bafc59a0537 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/index.ts @@ -0,0 +1,6 @@ +export { + isPageScope, + PAGE_SCOPES, + type PageScope, + serverScope, +} from "./pageScope"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/pageScope.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/pageScope.test.ts new file mode 100644 index 00000000000..570bc1edd63 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/pageScope.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { isPageScope, PAGE_SCOPES, serverScope } from "./pageScope"; + +describe("isPageScope", () => { + test("accepts every tab", () => { + for (const scope of PAGE_SCOPES) expect(isPageScope(scope)).toBe(true); + }); + + test("rejects anything else", () => { + expect(isPageScope("archived")).toBe(false); + expect(isPageScope("")).toBe(false); + expect(isPageScope(undefined)).toBe(false); + expect(isPageScope(null)).toBe(false); + expect(isPageScope(1)).toBe(false); + }); +}); + +describe("serverScope", () => { + test("passes the scopes the server filters on straight through", () => { + expect(serverScope("all")).toBe("all"); + expect(serverScope("team")).toBe("team"); + expect(serverScope("mine")).toBe("mine"); + }); + + test("pinned is not a server scope — it narrows by id instead", () => { + expect(serverScope("pinned")).toBe("all"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/pageScope.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/pageScope.ts new file mode 100644 index 00000000000..55b053c1d27 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/utils/pageScope/pageScope.ts @@ -0,0 +1,17 @@ +import type { PageListScope } from "@superset/trpc/page-schema"; + +/** + * The tabs. `pinned` is not a server scope: pins live in renderer storage, so + * the list asks for them by id instead of by a column. + */ +export const PAGE_SCOPES = ["all", "pinned", "team", "mine"] as const; + +export type PageScope = (typeof PAGE_SCOPES)[number]; + +export function isPageScope(value: unknown): value is PageScope { + return (PAGE_SCOPES as readonly unknown[]).includes(value); +} + +export function serverScope(scope: PageScope): PageListScope { + return scope === "pinned" ? "all" : scope; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts index e48cd759817..9cf0d8aeb76 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts @@ -1,10 +1,19 @@ -const PAGES_PER_BATCH = 200; +import type { PageListScope } from "@superset/trpc/page-schema"; + +export const PAGES_PER_BATCH = 50; export interface PagesListFilter { workspaceId?: string; search?: string; + scope?: PageListScope; + authorId?: string; + ids?: string[]; + limit?: number; } -export function pagesListInput(filter: PagesListFilter = {}) { - return { limit: PAGES_PER_BATCH, ...filter }; +export function pagesListInput({ + limit = PAGES_PER_BATCH, + ...filter +}: PagesListFilter = {}) { + return { limit, ...filter }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx index 42d083e0aca..19f750d21e2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx @@ -7,12 +7,12 @@ import { ArrowLeft, FileText, LayoutGrid, Plus } from "lucide-react"; import { type MouseEvent, useCallback, useMemo, useState } from "react"; import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import { useAllPages } from "renderer/routes/_authenticated/_dashboard/hooks/useAllPages"; import { isPaneModifier, useOpenPage, } from "renderer/routes/_authenticated/_dashboard/hooks/useOpenPage"; import { usePageFavorites } from "renderer/routes/_authenticated/_dashboard/hooks/usePageFavorites"; +import { usePagesList } from "renderer/routes/_authenticated/_dashboard/hooks/usePagesList"; import { pagesListInput } from "renderer/routes/_authenticated/_dashboard/utils/pagesListInput"; import type { CreateNewAgentSession } from "../../hooks/useAgentSessionLauncher"; import { NewPageComposer } from "./components/NewPageComposer"; @@ -23,6 +23,8 @@ import { } from "./stores/pagesMenuSeenStore"; import { type MenuPage, selectMenuPages } from "./utils/selectMenuPages"; +const MENU_PAGE_LIMIT = 200; + interface WorkspacePagesMenuProps { workspaceId: string; onCreateNewAgentSession: CreateNewAgentSession; @@ -45,12 +47,15 @@ export function WorkspacePagesMenu({ const [open, setOpen] = useState(false); const [composing, setComposing] = useState(false); - const workspacePagesQuery = useAllPages( - { workspaceId }, + // This menu orders by publish time, not creation time, so it takes one + // large batch rather than the grid's scroll-sized one. + const workspacePagesQuery = usePagesList( + { workspaceId, limit: MENU_PAGE_LIMIT }, { staleTime: 60_000 }, ); - const orgPagesQuery = useAllPages( - {}, + // Only the pins, by id — this menu never needed the rest of the org. + const pinnedPagesQuery = usePagesList( + { ids: favoritePageIds, limit: MENU_PAGE_LIMIT }, { enabled: open && favoritePageIds.length > 0, staleTime: 60_000 }, ); @@ -67,11 +72,16 @@ export function WorkspacePagesMenu({ () => selectMenuPages({ workspacePages: workspacePagesQuery.items, - orgPages: orgPagesQuery.items, + orgPages: pinnedPagesQuery.items, favoritePageIds, seenAt, }), - [workspacePagesQuery.items, orgPagesQuery.items, favoritePageIds, seenAt], + [ + workspacePagesQuery.items, + pinnedPagesQuery.items, + favoritePageIds, + seenAt, + ], ); const handleOpenChange = (next: boolean) => { diff --git a/apps/mobile/screens/(authenticated)/pages/PagesScreen.tsx b/apps/mobile/screens/(authenticated)/pages/PagesScreen.tsx index 7b2796c9e73..e6a3622fbfd 100644 --- a/apps/mobile/screens/(authenticated)/pages/PagesScreen.tsx +++ b/apps/mobile/screens/(authenticated)/pages/PagesScreen.tsx @@ -2,27 +2,23 @@ import { LegendList } from "@legendapp/list/react-native"; import { useLingui } from "@lingui/react/macro"; import * as Haptics from "expo-haptics"; import { Stack, useRouter } from "expo-router"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { RefreshControl, View } from "react-native"; import { Spinner } from "@/components/ui/spinner"; import { Text } from "@/components/ui/text"; import { PageRow } from "./components/PageRow"; -import { NO_PAGES, type OrgPage, usePagesQuery } from "./hooks/usePages"; -import { matchesScope, usePagesFilterStore } from "./stores/pagesFilterStore"; +import { type OrgPage, usePagesQuery } from "./hooks/usePages"; +import { usePagesFilterStore } from "./stores/pagesFilterStore"; export function PagesScreen() { const { t } = useLingui(); const router = useRouter(); const [refreshing, setRefreshing] = useState(false); - const pages = usePagesQuery(); const scope = usePagesFilterStore((state) => state.scope); const hasHydrated = usePagesFilterStore((state) => state.hasHydrated); + const pages = usePagesQuery(scope); const offline = pages.status === "pending" && pages.fetchStatus === "paused"; - - const visible = useMemo( - () => (pages.data ?? NO_PAGES).filter((page) => matchesScope(page, scope)), - [pages.data, scope], - ); + const visible = pages.items; const onRefresh = useCallback(async () => { setRefreshing(true); @@ -68,6 +64,12 @@ export function PagesScreen() { extraData={renderItem} keyExtractor={(page) => page.id} renderItem={renderItem} + onEndReachedThreshold={0.5} + onEndReached={() => { + if (pages.hasNextPage && !pages.isFetchingNextPage) { + void pages.fetchNextPage(); + } + }} refreshControl={ } diff --git a/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts b/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts index b82407b576c..606a8fba314 100644 --- a/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts +++ b/apps/mobile/screens/(authenticated)/pages/hooks/usePages/usePages.ts @@ -1,5 +1,11 @@ import type { RouterOutputs } from "@superset/trpc"; -import { type UseQueryResult, useQuery } from "@tanstack/react-query"; +import type { PageListScope } from "@superset/trpc/page-schema"; +import { + type UseQueryResult, + useInfiniteQuery, + useQuery, +} from "@tanstack/react-query"; +import { useMemo } from "react"; import { useSession } from "@/lib/auth/client"; import { apiClient } from "@/lib/trpc/client"; @@ -8,50 +14,72 @@ export type PulledPage = RouterOutputs["page"]["pull"]; export const NO_PAGES: OrgPage[] = []; -const PAGES_PER_REQUEST = 200; +const PAGES_PER_REQUEST = 50; -async function fetchAllPages( - filter: { workspaceId?: string }, - signal?: AbortSignal, -): Promise { - const items: OrgPage[] = []; - let cursor: { updatedAt: string; id: string } | undefined; - do { - const result = await apiClient.page.list.query( - { limit: PAGES_PER_REQUEST, ...filter, ...(cursor ? { cursor } : {}) }, - { signal }, - ); - items.push(...result.items); - cursor = result.nextCursor ?? undefined; - } while (cursor); - return items; -} - -export function usePagesQuery(): UseQueryResult { +/** One batch per `onEndReached`, rather than every page up front. */ +export function usePagesQuery(scope: PageListScope = "all") { const { data: session } = useSession(); const organizationId = session?.session?.activeOrganizationId ?? null; - return useQuery({ - queryKey: ["cloud", "page", "list", organizationId], + const query = useInfiniteQuery({ + queryKey: ["cloud", "page", "list", organizationId, scope], enabled: organizationId !== null, - queryFn: ({ signal }) => fetchAllPages({}, signal), + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + apiClient.page.list.query( + { + limit: PAGES_PER_REQUEST, + scope, + ...(pageParam ? { cursor: pageParam } : {}), + }, + { signal }, + ), + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, staleTime: 30_000, }); + + const items = useMemo( + () => query.data?.pages.flatMap((page) => page.items) ?? NO_PAGES, + [query.data], + ); + + return { ...query, items }; } -export function useWorkspacePagesQuery( - workspaceId: string | null, -): UseQueryResult { +export function useWorkspacePagesQuery(workspaceId: string | null) { const { data: session } = useSession(); const organizationId = session?.session?.activeOrganizationId ?? null; - return useQuery({ - queryKey: ["cloud", "page", "list", organizationId, workspaceId], + const query = useInfiniteQuery({ + queryKey: [ + "cloud", + "page", + "list", + organizationId, + "workspace", + workspaceId, + ], enabled: organizationId !== null && workspaceId !== null, - queryFn: ({ signal }) => - fetchAllPages({ workspaceId: workspaceId ?? "" }, signal), + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + apiClient.page.list.query( + { + limit: PAGES_PER_REQUEST, + workspaceId: workspaceId ?? "", + ...(pageParam ? { cursor: pageParam } : {}), + }, + { signal }, + ), + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, staleTime: 30_000, }); + + const items = useMemo( + () => query.data?.pages.flatMap((page) => page.items) ?? NO_PAGES, + [query.data], + ); + + return { ...query, items }; } const PULLED_PAGE_STALE_MS = 5 * 60_000; diff --git a/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/index.ts b/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/index.ts index 87ba74095f2..642b19abecc 100644 --- a/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/index.ts +++ b/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/index.ts @@ -1,5 +1,4 @@ export { - matchesScope, PAGE_SCOPES, type PageScope, usePagesFilterStore, diff --git a/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/pagesFilterStore.ts b/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/pagesFilterStore.ts index c3e57057b1f..03a4ce519d6 100644 --- a/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/pagesFilterStore.ts +++ b/apps/mobile/screens/(authenticated)/pages/stores/pagesFilterStore/pagesFilterStore.ts @@ -27,17 +27,3 @@ export const usePagesFilterStore = create()( }, ), ); - -export function matchesScope( - page: { visibility: string }, - scope: PageScope, -): boolean { - switch (scope) { - case "team": - return page.visibility !== "just_me"; - case "mine": - return page.visibility === "just_me"; - default: - return true; - } -} diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/actions/components/WorkspacePages/WorkspacePages.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/actions/components/WorkspacePages/WorkspacePages.tsx index 474e5c3be26..b2946213eb5 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/actions/components/WorkspacePages/WorkspacePages.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/actions/components/WorkspacePages/WorkspacePages.tsx @@ -6,7 +6,6 @@ import { Pressable, View } from "react-native"; import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; import { - NO_PAGES, type OrgPage, useWorkspacePagesQuery, } from "@/screens/(authenticated)/pages/hooks/usePages"; @@ -55,7 +54,7 @@ export function WorkspacePages({ workspaceId: string | null; }) { const pages = useWorkspacePagesQuery(workspaceId); - const items = pages.data ?? NO_PAGES; + const items = pages.items; if (items.length === 0) return null; diff --git a/packages/cli/src/commands/pages/list/command.ts b/packages/cli/src/commands/pages/list/command.ts index d1495ea542d..735cb695ff0 100644 --- a/packages/cli/src/commands/pages/list/command.ts +++ b/packages/cli/src/commands/pages/list/command.ts @@ -2,12 +2,7 @@ import { number, string, table } from "@superset/cli-framework"; import { PAGE_LIST_MAX_LIMIT } from "@superset/trpc/page-schema"; import { type CliContext, command } from "../../../lib/command"; import { listWorkspacesOnHost } from "../../../lib/host-workspaces"; -import { - decodeCursor, - encodeCursor, - fetchAllPages, - fetchPageList, -} from "../pageList"; +import { fetchAllPages, fetchPageList } from "../pageList"; import { resolveWorkspaceId } from "../workspaceRef"; interface WorkspaceLink { @@ -137,15 +132,11 @@ export default command({ }; } - const result = await fetchPageList( - ctx, - query, - options.cursor ? decodeCursor(options.cursor) : undefined, - ); + const result = await fetchPageList(ctx, query, options.cursor); return { data: { items: nameLinks(result.items, names), - nextCursor: result.nextCursor ? encodeCursor(result.nextCursor) : null, + nextCursor: result.nextCursor, }, }; }, diff --git a/packages/cli/src/commands/pages/pageList.test.ts b/packages/cli/src/commands/pages/pageList.test.ts deleted file mode 100644 index bf0b092cbd8..00000000000 --- a/packages/cli/src/commands/pages/pageList.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { CLIError } from "@superset/cli-framework"; -import { decodeCursor, encodeCursor } from "./pageList"; - -const PAGE = "00000000-0000-4000-8000-000000000002"; -const UPDATED_AT = "2026-09-14 10:00:00.123456+00"; - -describe("page list cursor", () => { - test("survives a round trip through the command line", () => { - const encoded = encodeCursor({ updatedAt: UPDATED_AT, id: PAGE }); - expect(decodeCursor(encoded)).toEqual({ - updatedAt: UPDATED_AT, - id: PAGE, - }); - }); - - test("keeps the sub-millisecond digits Postgres reported", () => { - const decoded = decodeCursor( - encodeCursor({ updatedAt: UPDATED_AT, id: PAGE }), - ); - expect(decoded.updatedAt).toContain(".123456"); - }); - - test("encodes without characters a shell would mangle", () => { - const encoded = encodeCursor({ updatedAt: UPDATED_AT, id: PAGE }); - expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); - }); - - test("reports a typo'd cursor as a CLI error, not a parse crash", () => { - expect(() => decodeCursor("not-a-cursor")).toThrow(CLIError); - }); - - test("rejects a well-formed token missing the id", () => { - const encoded = Buffer.from( - JSON.stringify({ updatedAt: UPDATED_AT }), - ).toString("base64url"); - expect(() => decodeCursor(encoded)).toThrow(CLIError); - }); - - test("rejects a token whose timestamp is not a string", () => { - const encoded = Buffer.from( - JSON.stringify({ updatedAt: 12345, id: PAGE }), - ).toString("base64url"); - expect(() => decodeCursor(encoded)).toThrow(CLIError); - }); -}); diff --git a/packages/cli/src/commands/pages/pageList.ts b/packages/cli/src/commands/pages/pageList.ts index e89feb2109f..ecdd0812b18 100644 --- a/packages/cli/src/commands/pages/pageList.ts +++ b/packages/cli/src/commands/pages/pageList.ts @@ -1,11 +1,8 @@ -import { CLIError } from "@superset/cli-framework"; +import type { RouterOutputs } from "@superset/trpc"; import { PAGE_LIST_MAX_LIMIT } from "@superset/trpc/page-schema"; import type { CliContext } from "../../lib/command"; -export interface PageListCursor { - updatedAt: string; - id: string; -} +type PageList = RouterOutputs["page"]["list"]; export interface PageListQuery { workspaceId?: string; @@ -15,18 +12,19 @@ export interface PageListQuery { export interface PageListPage { items: TPage[]; - nextCursor: PageListCursor | null; + nextCursor: PageList["nextCursor"]; } export async function fetchPageList( ctx: CliContext, query: PageListQuery, - cursor?: PageListCursor, + cursor?: string, ): Promise> { - return (await ctx.api.page.list.query({ + const result = await ctx.api.page.list.query({ ...query, ...(cursor ? { cursor } : {}), - })) as unknown as PageListPage; + }); + return result as PageListPage; } export async function fetchAllPages( @@ -35,7 +33,7 @@ export async function fetchAllPages( ): Promise { const batched = { limit: PAGE_LIST_MAX_LIMIT, ...query }; const items: TPage[] = []; - let cursor: PageListCursor | undefined; + let cursor: string | undefined; do { const result = await fetchPageList(ctx, batched, cursor); items.push(...result.items); @@ -43,30 +41,3 @@ export async function fetchAllPages( } while (cursor); return items; } - -export function encodeCursor(cursor: PageListCursor): string { - return Buffer.from( - JSON.stringify({ updatedAt: cursor.updatedAt, id: cursor.id }), - ).toString("base64url"); -} - -export function decodeCursor(value: string): PageListCursor { - const invalid = () => - new CLIError( - "Could not read --cursor", - "Pass the nextCursor from a previous --json run, or drop the flag to start over", - ); - - let parsed: unknown; - try { - parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); - } catch { - throw invalid(); - } - - const cursor = parsed as Partial | null; - if (typeof cursor?.id !== "string" || typeof cursor.updatedAt !== "string") { - throw invalid(); - } - return { updatedAt: cursor.updatedAt, id: cursor.id }; -} diff --git a/packages/db/drizzle/0120_pages_created_at_keyset_index.sql b/packages/db/drizzle/0120_pages_created_at_keyset_index.sql new file mode 100644 index 00000000000..7e7923f62a7 --- /dev/null +++ b/packages/db/drizzle/0120_pages_created_at_keyset_index.sql @@ -0,0 +1 @@ +CREATE INDEX "pages_organization_id_created_at_id_idx" ON "pages" USING btree ("organization_id","created_at" desc,"id" desc); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0120_snapshot.json b/packages/db/drizzle/meta/0120_snapshot.json new file mode 100644 index 00000000000..c3ff5d71a35 --- /dev/null +++ b/packages/db/drizzle/meta/0120_snapshot.json @@ -0,0 +1,11074 @@ +{ + "id": "60ef1f77-8f48-4241-b80f-53aec6d774df", + "prevId": "e2dbc465-8d48-4ac6-957e-cf51815f7afe", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.apikeys": { + "name": "apikeys", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n\t\t\t\tWHEN metadata IS NULL OR metadata = '' THEN NULL\n\t\t\t\tWHEN NOT (metadata IS JSON OBJECT) THEN NULL\n\t\t\t\tWHEN (metadata::jsonb->>'organizationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'\n\t\t\t\t\tTHEN (metadata::jsonb->>'organizationId')::uuid\n\t\t\t\tELSE NULL\n\t\t\tEND", + "type": "stored" + } + } + }, + "indexes": { + "apikeys_configId_idx": { + "name": "apikeys_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_referenceId_idx": { + "name": "apikeys_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_organization_id_idx": { + "name": "apikeys_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikeys_metadata_trgm_idx": { + "name": "apikeys_metadata_trgm_idx", + "columns": [ + { + "expression": "\"metadata\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.device_codes": { + "name": "device_codes", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "tableTo": "teams", + "schemaTo": "auth", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.jwkss": { + "name": "jwkss", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_access_tokens": { + "name": "oauth_access_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_access_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_session_id_sessions_id_fk": { + "name": "oauth_access_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk": { + "name": "oauth_access_tokens_refresh_id_oauth_refresh_tokens_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_refresh_tokens", + "schemaTo": "auth", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_clients": { + "name": "oauth_clients", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_consents": { + "name": "oauth_consents", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consents_client_id_oauth_clients_client_id_fk": { + "name": "oauth_consents_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk": { + "name": "oauth_refresh_tokens_client_id_oauth_clients_client_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "schemaTo": "auth", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_session_id_sessions_id_fk": { + "name": "oauth_refresh_tokens_session_id_sessions_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "sessions", + "schemaTo": "auth", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_domains": { + "name": "allowed_domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_allowed_domains_idx": { + "name": "organizations_allowed_domains_idx", + "columns": [ + { + "expression": "allowed_domains", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.team_members": { + "name": "team_members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_user_id_idx": { + "name": "team_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_organization_id_idx": { + "name": "team_members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_team_user_unique": { + "name": "team_members_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "schemaTo": "auth", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_organization_id_organizations_id_fk": { + "name": "team_members_organization_id_organizations_id_fk", + "tableFrom": "team_members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.teams": { + "name": "teams", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_org_slug_unique": { + "name": "teams_org_slug_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "last_active_organization_id": { + "name": "last_active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deletion_requested_at": { + "name": "deletion_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_organization_ids_idx": { + "name": "users_organization_ids_idx", + "columns": [ + { + "expression": "organization_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_org_id_idx": { + "name": "github_pull_requests_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pull_requests_organization_id_organizations_id_fk": { + "name": "github_pull_requests_organization_id_organizations_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_org_id_idx": { + "name": "github_repositories_org_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_repositories_organization_id_organizations_id_fk": { + "name": "github_repositories_organization_id_organizations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leaderboard_daily": { + "name": "leaderboard_daily", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uncached_input": { + "name": "uncached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input": { + "name": "cached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_5m": { + "name": "cache_write_5m", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_1h": { + "name": "cache_write_1h", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output": { + "name": "output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_output": { + "name": "reasoning_output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens": { + "name": "tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usd_estimate": { + "name": "usd_estimate", + "type": "numeric(14, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "approximate": { + "name": "approximate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leaderboard_daily_day_idx": { + "name": "leaderboard_daily_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_daily_user_day_idx": { + "name": "leaderboard_daily_user_day_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_daily_model_day_idx": { + "name": "leaderboard_daily_model_day_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leaderboard_daily_user_id_public_profiles_user_id_fk": { + "name": "leaderboard_daily_user_id_public_profiles_user_id_fk", + "tableFrom": "leaderboard_daily", + "tableTo": "public_profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "leaderboard_daily_identity_key": { + "name": "leaderboard_daily_identity_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "day", + "provider", + "model", + "host_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leaderboard_daily_factory": { + "name": "leaderboard_daily_factory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parallel_sessions": { + "name": "parallel_sessions", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "agent_prs_merged": { + "name": "agent_prs_merged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leaderboard_daily_factory_day_idx": { + "name": "leaderboard_daily_factory_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_daily_factory_user_day_idx": { + "name": "leaderboard_daily_factory_user_day_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leaderboard_daily_factory_user_id_public_profiles_user_id_fk": { + "name": "leaderboard_daily_factory_user_id_public_profiles_user_id_fk", + "tableFrom": "leaderboard_daily_factory", + "tableTo": "public_profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "leaderboard_daily_factory_identity_key": { + "name": "leaderboard_daily_factory_identity_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "day", + "host_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leaderboard_participants": { + "name": "leaderboard_participants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "leaderboard_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "opted_in_at": { + "name": "opted_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "flagged_at": { + "name": "flagged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_published_at": { + "name": "last_published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload_version": { + "name": "payload_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "tokens": { + "name": "tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usd": { + "name": "usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "uncached_input": { + "name": "uncached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input": { + "name": "cached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_5m": { + "name": "cache_write_5m", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_1h": { + "name": "cache_write_1h", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output": { + "name": "output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_output": { + "name": "reasoning_output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "approximate": { + "name": "approximate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "day_range_start": { + "name": "day_range_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "day_range_end": { + "name": "day_range_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tier_computed_at": { + "name": "tier_computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active_days": { + "name": "active_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "axis_width": { + "name": "axis_width", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "axis_depth": { + "name": "axis_depth", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "axis_output": { + "name": "axis_output", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "axis_cost": { + "name": "axis_cost", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leaderboard_participants_tokens_idx": { + "name": "leaderboard_participants_tokens_idx", + "columns": [ + { + "expression": "tokens", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_participants_org_idx": { + "name": "leaderboard_participants_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_participants_usd_idx": { + "name": "leaderboard_participants_usd_idx", + "columns": [ + { + "expression": "usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leaderboard_participants_tier_idx": { + "name": "leaderboard_participants_tier_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leaderboard_participants_user_id_users_id_fk": { + "name": "leaderboard_participants_user_id_users_id_fk", + "tableFrom": "leaderboard_participants", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leaderboard_participants_organization_id_organizations_id_fk": { + "name": "leaderboard_participants_organization_id_organizations_id_fk", + "tableFrom": "leaderboard_participants", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "leaderboard_participants_handle_unique": { + "name": "leaderboard_participants_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_installs": { + "name": "plugin_installs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "marketplace": { + "name": "marketplace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_name": { + "name": "plugin_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_installs_user_plugin_unique": { + "name": "plugin_installs_user_plugin_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "marketplace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_installs_user_idx": { + "name": "plugin_installs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_installs_organization_id_organizations_id_fk": { + "name": "plugin_installs_organization_id_organizations_id_fk", + "tableFrom": "plugin_installs", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_installs_user_id_users_id_fk": { + "name": "plugin_installs_user_id_users_id_fk", + "tableFrom": "plugin_installs", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_marketplaces": { + "name": "plugin_marketplaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_marketplaces_user_name_unique": { + "name": "plugin_marketplaces_user_name_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "plugin_marketplaces_user_idx": { + "name": "plugin_marketplaces_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_marketplaces_organization_id_organizations_id_fk": { + "name": "plugin_marketplaces_organization_id_organizations_id_fk", + "tableFrom": "plugin_marketplaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_marketplaces_user_id_users_id_fk": { + "name": "plugin_marketplaces_user_id_users_id_fk", + "tableFrom": "plugin_marketplaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_oauth_clients": { + "name": "plugin_oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_secret_expires_at": { + "name": "client_secret_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "registration_access_token": { + "name": "registration_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_client_uri": { + "name": "registration_client_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_oauth_clients_issuer_redirect_unique": { + "name": "plugin_oauth_clients_issuer_redirect_unique", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "redirect_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.handles": { + "name": "handles", + "schema": "", + "columns": { + "handle": { + "name": "handle", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "handle_owner_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "handles_owner_type_idx": { + "name": "handles_owner_type_idx", + "columns": [ + { + "expression": "owner_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "handles_user_id_users_id_fk": { + "name": "handles_user_id_users_id_fk", + "tableFrom": "handles", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "handles_organization_id_organizations_id_fk": { + "name": "handles_organization_id_organizations_id_fk", + "tableFrom": "handles", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "handles_user_key": { + "name": "handles_user_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "handles_organization_key": { + "name": "handles_organization_key", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + }, + "handles_user_owner_key": { + "name": "handles_user_owner_key", + "nullsNotDistinct": false, + "columns": [ + "handle", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "handles_owner_matches_type": { + "name": "handles_owner_matches_type", + "value": "(\n\t\t\t\t(\"handles\".\"owner_type\" = 'user' and \"handles\".\"user_id\" is not null and \"handles\".\"organization_id\" is null)\n\t\t\t\tor (\"handles\".\"owner_type\" = 'organization' and \"handles\".\"organization_id\" is not null and \"handles\".\"user_id\" is null)\n\t\t\t\tor (\"handles\".\"owner_type\" = 'reserved' and \"handles\".\"user_id\" is null and \"handles\".\"organization_id\" is null)\n\t\t\t)" + }, + "handles_shape": { + "name": "handles_shape", + "value": "length(\"handles\".\"handle\") between 2 and 39 and \"handles\".\"handle\" ~ '^[a-z0-9]+(-[a-z0-9]+)*$'" + } + }, + "isRLSEnabled": false + }, + "public.profile_awards": { + "name": "profile_awards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "value": { + "name": "value", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "awarded_on": { + "name": "awarded_on", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "awarded_at": { + "name": "awarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "profile_awards_user_idx": { + "name": "profile_awards_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "profile_awards_slug_idx": { + "name": "profile_awards_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "awarded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "profile_awards_user_id_public_profiles_user_id_fk": { + "name": "profile_awards_user_id_public_profiles_user_id_fk", + "tableFrom": "profile_awards", + "tableTo": "public_profiles", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_awards_identity_key": { + "name": "profile_awards_identity_key", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "slug", + "tier" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_profiles": { + "name": "public_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "leaderboard_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_handle": { + "name": "github_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x_handle": { + "name": "x_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "opted_in_at": { + "name": "opted_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "flagged_at": { + "name": "flagged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_published_at": { + "name": "last_published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload_version": { + "name": "payload_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "awards_catalog_version": { + "name": "awards_catalog_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tokens": { + "name": "tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usd": { + "name": "usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sessions": { + "name": "sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "uncached_input": { + "name": "uncached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input": { + "name": "cached_input", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_5m": { + "name": "cache_write_5m", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_1h": { + "name": "cache_write_1h", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output": { + "name": "output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_output": { + "name": "reasoning_output", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "approximate": { + "name": "approximate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "day_range_start": { + "name": "day_range_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "day_range_end": { + "name": "day_range_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tier_computed_at": { + "name": "tier_computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active_days": { + "name": "active_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "axis_width": { + "name": "axis_width", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "axis_depth": { + "name": "axis_depth", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "axis_output": { + "name": "axis_output", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "axis_cost": { + "name": "axis_cost", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_profiles_tokens_idx": { + "name": "public_profiles_tokens_idx", + "columns": [ + { + "expression": "tokens", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_profiles_org_idx": { + "name": "public_profiles_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_profiles_usd_idx": { + "name": "public_profiles_usd_idx", + "columns": [ + { + "expression": "usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_profiles_tier_idx": { + "name": "public_profiles_tier_idx", + "columns": [ + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_profiles_user_id_users_id_fk": { + "name": "public_profiles_user_id_users_id_fk", + "tableFrom": "public_profiles", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_profiles_organization_id_organizations_id_fk": { + "name": "public_profiles_organization_id_organizations_id_fk", + "tableFrom": "public_profiles", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "public_profiles_handle_owner_fk": { + "name": "public_profiles_handle_owner_fk", + "tableFrom": "public_profiles", + "tableTo": "handles", + "columnsFrom": [ + "handle", + "user_id" + ], + "columnsTo": [ + "handle", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "public_profiles_handle_unique": { + "name": "public_profiles_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": { + "public_profiles_bio_length": { + "name": "public_profiles_bio_length", + "value": "length(\"public_profiles\".\"bio\") <= 160" + }, + "public_profiles_website_scheme": { + "name": "public_profiles_website_scheme", + "value": "\"public_profiles\".\"website_url\" is null or \"public_profiles\".\"website_url\" ~ '^https://'" + } + }, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_credentials": { + "name": "agent_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "agent_credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_label": { + "name": "account_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_credentials_user_id_idx": { + "name": "agent_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_credentials_user_id_users_id_fk": { + "name": "agent_credentials_user_id_users_id_fk", + "tableFrom": "agent_credentials", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_credentials_user_id_agent_unique": { + "name": "agent_credentials_user_id_agent_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "agent" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_kind": { + "name": "parent_kind", + "type": "attachment_parent_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_file_id_idx": { + "name": "attachments_file_id_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_parent_kind_parent_id_idx": { + "name": "attachments_parent_kind_parent_id_idx", + "columns": [ + { + "expression": "parent_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_parent_path_unique": { + "name": "attachments_parent_path_unique", + "columns": [ + { + "expression": "parent_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"attachments\".\"path\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_file_id_files_id_fk": { + "name": "attachments_file_id_files_id_fk", + "tableFrom": "attachments", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_events": { + "name": "automation_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "integration_connection_id": { + "name": "integration_connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_event_id": { + "name": "external_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_login": { + "name": "actor_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_is_external": { + "name": "actor_is_external", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "webhook_event_id": { + "name": "webhook_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dispatch_input": { + "name": "dispatch_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "automation_events_undispatched_idx": { + "name": "automation_events_undispatched_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"automation_events\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_events_org_received_idx": { + "name": "automation_events_org_received_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_events_resource_idx": { + "name": "automation_events_resource_idx", + "columns": [ + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_events_prunable_idx": { + "name": "automation_events_prunable_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"automation_events\".\"payload\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_events_organization_id_organizations_id_fk": { + "name": "automation_events_organization_id_organizations_id_fk", + "tableFrom": "automation_events", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "automation_events_dedup_unique": { + "name": "automation_events_dedup_unique", + "nullsNotDistinct": true, + "columns": [ + "integration_connection_id", + "provider", + "external_event_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_prompt_versions": { + "name": "automation_prompt_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_bucket": { + "name": "window_bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "automation_prompt_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "restored_from_version_id": { + "name": "restored_from_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_prompt_versions_bucket_uniq": { + "name": "automation_prompt_versions_bucket_uniq", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"automation_prompt_versions\".\"source\" <> 'restore'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_prompt_versions_automation_idx": { + "name": "automation_prompt_versions_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_prompt_versions_automation_id_automations_id_fk": { + "name": "automation_prompt_versions_automation_id_automations_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_author_user_id_users_id_fk": { + "name": "automation_prompt_versions_author_user_id_users_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_prompt_versions_restored_from_version_id_fk": { + "name": "automation_prompt_versions_restored_from_version_id_fk", + "tableFrom": "automation_prompt_versions", + "tableTo": "automation_prompt_versions", + "columnsFrom": [ + "restored_from_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_runs": { + "name": "automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_kind": { + "name": "session_kind", + "type": "automation_session_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "chat_session_id": { + "name": "chat_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "terminal_session_id": { + "name": "terminal_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "automation_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "automation_run_error_code", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_runs_schedule_dedup_idx": { + "name": "automation_runs_schedule_dedup_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "scheduled_for IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_event_dedup_idx": { + "name": "automation_runs_event_dedup_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "event_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_inflight_resource_idx": { + "name": "automation_runs_inflight_resource_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status IN ('dispatching', 'dispatched')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_history_idx": { + "name": "automation_runs_history_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_status_idx": { + "name": "automation_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_runs_workspace_idx": { + "name": "automation_runs_workspace_idx", + "columns": [ + { + "expression": "v2_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_runs_organization_id_organizations_id_fk": { + "name": "automation_runs_organization_id_organizations_id_fk", + "tableFrom": "automation_runs", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_runs_trigger_id_automation_triggers_id_fk": { + "name": "automation_runs_trigger_id_automation_triggers_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automation_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_event_id_automation_events_id_fk": { + "name": "automation_runs_event_id_automation_events_id_fk", + "tableFrom": "automation_runs", + "tableTo": "automation_events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_chat_session_id_chat_sessions_id_fk": { + "name": "automation_runs_chat_session_id_chat_sessions_id_fk", + "tableFrom": "automation_runs", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "automation_runs_automation_org_fk": { + "name": "automation_runs_automation_org_fk", + "tableFrom": "automation_runs", + "tableTo": "automations", + "columnsFrom": [ + "automation_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_triggers": { + "name": "automation_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "automation_id": { + "name": "automation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "automation_trigger_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_prefix": { + "name": "secret_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_triggers_dispatcher_idx": { + "name": "automation_triggers_dispatcher_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "kind = 'schedule'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_matcher_idx": { + "name": "automation_triggers_matcher_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_automation_idx": { + "name": "automation_triggers_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automation_triggers_schedule_idx": { + "name": "automation_triggers_schedule_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "kind = 'schedule'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_triggers_organization_id_organizations_id_fk": { + "name": "automation_triggers_organization_id_organizations_id_fk", + "tableFrom": "automation_triggers", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_triggers_automation_org_fk": { + "name": "automation_triggers_automation_org_fk", + "tableFrom": "automation_triggers", + "tableTo": "automations", + "columnsFrom": [ + "automation_id", + "organization_id" + ], + "columnsTo": [ + "id", + "organization_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "automation_triggers_kind_matches_config": { + "name": "automation_triggers_kind_matches_config", + "value": "config->>'kind' = kind::text" + } + }, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_host_id": { + "name": "target_host_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_project_id": { + "name": "v2_project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"automation\"]'::jsonb" + }, + "continue_agent_session": { + "name": "continue_agent_session", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automations_owner_idx": { + "name": "automations_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "automations_organization_idx": { + "name": "automations_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automations_organization_id_organizations_id_fk": { + "name": "automations_organization_id_organizations_id_fk", + "tableFrom": "automations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automations_owner_user_id_users_id_fk": { + "name": "automations_owner_user_id_users_id_fk", + "tableFrom": "automations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "automations_id_org_unique": { + "name": "automations_id_org_unique", + "nullsNotDistinct": false, + "columns": [ + "id", + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "v2_workspace_id": { + "name": "v2_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_sessions_org_idx": { + "name": "chat_sessions_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_created_by_idx": { + "name": "chat_sessions_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_sessions_last_active_idx": { + "name": "chat_sessions_last_active_idx", + "columns": [ + { + "expression": "last_active_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_created_by_users_id_fk": { + "name": "chat_sessions_created_by_users_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_workspace_id_workspaces_id_fk": { + "name": "chat_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_workspace_repositories": { + "name": "cloud_workspace_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cloud_workspace_id": { + "name": "cloud_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cloud_workspace_repositories_cloud_workspace_id_idx": { + "name": "cloud_workspace_repositories_cloud_workspace_id_idx", + "columns": [ + { + "expression": "cloud_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_workspace_repositories_cloud_workspace_id_cloud_workspaces_id_fk": { + "name": "cloud_workspace_repositories_cloud_workspace_id_cloud_workspaces_id_fk", + "tableFrom": "cloud_workspace_repositories", + "tableTo": "cloud_workspaces", + "columnsFrom": [ + "cloud_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_workspace_repositories_repository_id_github_repositories_id_fk": { + "name": "cloud_workspace_repositories_repository_id_github_repositories_id_fk", + "tableFrom": "cloud_workspace_repositories", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cloud_workspace_repositories_workspace_repository_unique": { + "name": "cloud_workspace_repositories_workspace_repository_unique", + "nullsNotDistinct": false, + "columns": [ + "cloud_workspace_id", + "repository_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_workspaces": { + "name": "cloud_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'vercel'" + }, + "provider_sandbox_id": { + "name": "provider_sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_url": { + "name": "sandbox_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "cloud_workspace_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioning'" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_version": { + "name": "host_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cloud_workspaces_organization_id_idx": { + "name": "cloud_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_workspaces_organization_id_organizations_id_fk": { + "name": "cloud_workspaces_organization_id_organizations_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_workspaces_environment_id_environments_id_fk": { + "name": "cloud_workspaces_environment_id_environments_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cloud_workspaces_created_by_user_id_users_id_fk": { + "name": "cloud_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "cloud_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cloud_workspaces_provider_sandbox_id_unique": { + "name": "cloud_workspaces_provider_sandbox_id_unique", + "nullsNotDistinct": false, + "columns": [ + "provider", + "provider_sandbox_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connections": { + "name": "connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connector": { + "name": "connector", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_kind": { + "name": "owner_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_method": { + "name": "auth_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_label": { + "name": "external_account_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_user_label": { + "name": "external_user_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connections_org_connector_unique": { + "name": "connections_org_connector_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"connections\".\"owner_kind\" = 'org'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_user_connector_unique": { + "name": "connections_user_connector_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"connections\".\"owner_kind\" = 'user'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_org_idx": { + "name": "connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_user_connector_idx": { + "name": "connections_user_connector_idx", + "columns": [ + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"connections\".\"disconnected_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "connections_external_account_idx": { + "name": "connections_external_account_idx", + "columns": [ + { + "expression": "connector", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connections_organization_id_organizations_id_fk": { + "name": "connections_organization_id_organizations_id_fk", + "tableFrom": "connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connections_connected_by_user_id_users_id_fk": { + "name": "connections_connected_by_user_id_users_id_fk", + "tableFrom": "connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "connections_user_identity_present": { + "name": "connections_user_identity_present", + "value": "owner_kind <> 'user' OR external_user_id IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.deal_redemptions": { + "name": "deal_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_redemption_id": { + "name": "external_redemption_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_id": { + "name": "deal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_batch": { + "name": "company_batch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promotion_code": { + "name": "promotion_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deal_redemptions_source_external_id_unique": { + "name": "deal_redemptions_source_external_id_unique", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_redemption_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deal_redemptions_email_idx": { + "name": "deal_redemptions_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deal_redemptions_organization_id_organizations_id_fk": { + "name": "deal_redemptions_organization_id_organizations_id_fk", + "tableFrom": "deal_redemptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.desktop_notices": { + "name": "desktop_notices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "severity": { + "name": "severity", + "type": "desktop_notice_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "desktop_notice_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'immediate'" + }, + "min_version": { + "name": "min_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_version": { + "name": "max_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platforms": { + "name": "platforms", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "channels": { + "name": "channels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta_label": { + "name": "cta_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cta_action": { + "name": "cta_action", + "type": "desktop_notice_cta_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cta_url": { + "name": "cta_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissible": { + "name": "dismissible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "desktop_notices_active_idx": { + "name": "desktop_notices_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_repositories": { + "name": "environment_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "environment_repositories_environment_id_idx": { + "name": "environment_repositories_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_repositories_environment_id_environments_id_fk": { + "name": "environment_repositories_environment_id_environments_id_fk", + "tableFrom": "environment_repositories", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_repositories_repository_id_github_repositories_id_fk": { + "name": "environment_repositories_repository_id_github_repositories_id_fk", + "tableFrom": "environment_repositories", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_repositories_environment_id_repository_id_unique": { + "name": "environment_repositories_environment_id_repository_id_unique", + "nullsNotDistinct": false, + "columns": [ + "environment_id", + "repository_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_secrets": { + "name": "environment_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_secrets_environment_id_idx": { + "name": "environment_secrets_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_secrets_organization_id_idx": { + "name": "environment_secrets_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_secrets_organization_id_organizations_id_fk": { + "name": "environment_secrets_organization_id_organizations_id_fk", + "tableFrom": "environment_secrets", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_secrets_environment_id_environments_id_fk": { + "name": "environment_secrets_environment_id_environments_id_fk", + "tableFrom": "environment_secrets", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_secrets_created_by_user_id_users_id_fk": { + "name": "environment_secrets_created_by_user_id_users_id_fk", + "tableFrom": "environment_secrets", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_secrets_environment_id_organization_id_key_unique": { + "name": "environment_secrets_environment_id_organization_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "environment_id", + "organization_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'vercel'" + }, + "source_kind": { + "name": "source_kind", + "type": "environment_source_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bundle_sha": { + "name": "bundle_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hooks_repository_id": { + "name": "hooks_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "environment_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environments_organization_id_idx": { + "name": "environments_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environments_organization_id_organizations_id_fk": { + "name": "environments_organization_id_organizations_id_fk", + "tableFrom": "environments", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_hooks_repository_id_github_repositories_id_fk": { + "name": "environments_hooks_repository_id_github_repositories_id_fk", + "tableFrom": "environments", + "tableTo": "github_repositories", + "columnsFrom": [ + "hooks_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environments_created_by_user_id_users_id_fk": { + "name": "environments_created_by_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environments_organization_id_name_unique": { + "name": "environments_organization_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "file_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "files_organization_id_created_at_idx": { + "name": "files_organization_id_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "files_status_created_at_idx": { + "name": "files_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "files_organization_id_organizations_id_fk": { + "name": "files_organization_id_organizations_id_fk", + "tableFrom": "files", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_created_by_user_id_users_id_fk": { + "name": "files_created_by_user_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_user_connections": { + "name": "github_user_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_access_token": { + "name": "encrypted_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "encrypted_refresh_token": { + "name": "encrypted_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "github_user_connections_user_id_users_id_fk": { + "name": "github_user_connections_user_id_users_id_fk", + "tableFrom": "github_user_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_user_connections_user_id_unique": { + "name": "github_user_connections_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnect_reason": { + "name": "disconnect_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_provider_unique": { + "name": "integration_connections_org_provider_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" <> 'google'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_google_user_unique": { + "name": "integration_connections_google_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" = 'google'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_slack_external_org_active_unique": { + "name": "integration_connections_slack_external_org_active_unique", + "columns": [ + { + "expression": "external_org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"integration_connections\".\"provider\" = 'slack' AND \"integration_connections\".\"disconnected_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.page_comment_threads": { + "name": "page_comment_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "page_id": { + "name": "page_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_version_id": { + "name": "page_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "anchor_kind": { + "name": "anchor_kind", + "type": "page_comment_anchor_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "page_comment_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "anchor_text": { + "name": "anchor_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_activated_at": { + "name": "agent_activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "agent_activated_by_user_id": { + "name": "agent_activated_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by_user_id": { + "name": "resolved_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "page_comment_threads_page_id_idx": { + "name": "page_comment_threads_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_comment_threads_page_version_id_idx": { + "name": "page_comment_threads_page_version_id_idx", + "columns": [ + { + "expression": "page_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_comment_threads_open_idx": { + "name": "page_comment_threads_open_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "resolved_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_comment_threads_page_id_pages_id_fk": { + "name": "page_comment_threads_page_id_pages_id_fk", + "tableFrom": "page_comment_threads", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_comment_threads_page_version_id_page_versions_id_fk": { + "name": "page_comment_threads_page_version_id_page_versions_id_fk", + "tableFrom": "page_comment_threads", + "tableTo": "page_versions", + "columnsFrom": [ + "page_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_comment_threads_created_by_user_id_users_id_fk": { + "name": "page_comment_threads_created_by_user_id_users_id_fk", + "tableFrom": "page_comment_threads", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "page_comment_threads_agent_activated_by_user_id_users_id_fk": { + "name": "page_comment_threads_agent_activated_by_user_id_users_id_fk", + "tableFrom": "page_comment_threads", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "agent_activated_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "page_comment_threads_resolved_by_user_id_users_id_fk": { + "name": "page_comment_threads_resolved_by_user_id_users_id_fk", + "tableFrom": "page_comment_threads", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "resolved_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "page_comment_threads_anchor_matches_kind": { + "name": "page_comment_threads_anchor_matches_kind", + "value": "(anchor_kind = 'page') = (anchor IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.page_comments": { + "name": "page_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_kind": { + "name": "author_kind", + "type": "page_comment_author_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "author_user_id": { + "name": "author_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_comments_thread_id_created_at_idx": { + "name": "page_comments_thread_id_created_at_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_comments_thread_id_page_comment_threads_id_fk": { + "name": "page_comments_thread_id_page_comment_threads_id_fk", + "tableFrom": "page_comments", + "tableTo": "page_comment_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_comments_author_user_id_users_id_fk": { + "name": "page_comments_author_user_id_users_id_fk", + "tableFrom": "page_comments", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "page_comments_agent_has_session": { + "name": "page_comments_agent_has_session", + "value": "author_kind <> 'agent' OR agent_session_id IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.page_versions": { + "name": "page_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "page_id": { + "name": "page_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_versions_page_id_idx": { + "name": "page_versions_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_versions_page_id_pages_id_fk": { + "name": "page_versions_page_id_pages_id_fk", + "tableFrom": "page_versions", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_versions_created_by_user_id_users_id_fk": { + "name": "page_versions_created_by_user_id_users_id_fk", + "tableFrom": "page_versions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "page_versions_page_id_version_unique": { + "name": "page_versions_page_id_version_unique", + "nullsNotDistinct": false, + "columns": [ + "page_id", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "page_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'just_me'" + }, + "shared_version": { + "name": "shared_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "watched_by_agent": { + "name": "watched_by_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "watch_heartbeat_at": { + "name": "watch_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pages_slug_unique": { + "name": "pages_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_organization_id_updated_at_idx": { + "name": "pages_organization_id_updated_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_organization_id_created_at_id_idx": { + "name": "pages_organization_id_created_at_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_created_by_user_id_idx": { + "name": "pages_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pages_organization_id_organizations_id_fk": { + "name": "pages_organization_id_organizations_id_fk", + "tableFrom": "pages", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pages_created_by_user_id_users_id_fk": { + "name": "pages_created_by_user_id_users_id_fk", + "tableFrom": "pages", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_organization_id_idx": { + "name": "projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_github_repository_id_github_repositories_id_fk": { + "name": "projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_org_slug_unique": { + "name": "projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submitted_prompts": { + "name": "submitted_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "prompt_text": { + "name": "prompt_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submitter_name": { + "name": "submitter_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "submitted_prompts_user_id_idx": { + "name": "submitted_prompts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_organization_id_idx": { + "name": "submitted_prompts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submitted_prompts_created_at_idx": { + "name": "submitted_prompts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submitted_prompts_user_id_users_id_fk": { + "name": "submitted_prompts_user_id_users_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submitted_prompts_organization_id_organizations_id_fk": { + "name": "submitted_prompts_organization_id_organizations_id_fk", + "tableFrom": "submitted_prompts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_updated_at": { + "name": "external_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_project_id": { + "name": "external_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_project_name": { + "name": "external_project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_cycle_id": { + "name": "external_cycle_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_cycle_name": { + "name": "external_cycle_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_external_id": { + "name": "assignee_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_display_name": { + "name": "assignee_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_avatar_url": { + "name": "assignee_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_project_id_idx": { + "name": "tasks_external_project_id_idx", + "columns": [ + { + "expression": "external_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_project_name_idx": { + "name": "tasks_external_project_name_idx", + "columns": [ + { + "expression": "external_project_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_cycle_id_idx": { + "name": "tasks_external_cycle_id_idx", + "columns": [ + { + "expression": "external_cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_external_id_idx": { + "name": "tasks_assignee_external_id_idx", + "columns": [ + { + "expression": "assignee_external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + }, + "tasks_org_slug_unique": { + "name": "tasks_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_identities": { + "name": "user_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_scope_id": { + "name": "external_scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_identities_user_idx": { + "name": "user_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_identities_org_provider_idx": { + "name": "user_identities_org_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_identities_organization_id_organizations_id_fk": { + "name": "user_identities_organization_id_organizations_id_fk", + "tableFrom": "user_identities", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_identities_account_unique": { + "name": "user_identities_account_unique", + "nullsNotDistinct": true, + "columns": [ + "organization_id", + "provider", + "external_scope_id", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_clients": { + "name": "v2_clients", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_clients_organization_id_idx": { + "name": "v2_clients_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_clients_user_id_idx": { + "name": "v2_clients_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_clients_organization_id_organizations_id_fk": { + "name": "v2_clients_organization_id_organizations_id_fk", + "tableFrom": "v2_clients", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_clients_user_id_users_id_fk": { + "name": "v2_clients_user_id_users_id_fk", + "tableFrom": "v2_clients", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_clients_organization_id_user_id_machine_id_pk": { + "name": "v2_clients_organization_id_user_id_machine_id_pk", + "columns": [ + "organization_id", + "user_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_hosts": { + "name": "v2_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wake_command": { + "name": "wake_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "install_source": { + "name": "install_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_hosts_organization_id_idx": { + "name": "v2_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_hosts_organization_id_organizations_id_fk": { + "name": "v2_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_hosts_created_by_user_id_users_id_fk": { + "name": "v2_hosts_created_by_user_id_users_id_fk", + "tableFrom": "v2_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_hosts_organization_id_machine_id_pk": { + "name": "v2_hosts_organization_id_machine_id_pk", + "columns": [ + "organization_id", + "machine_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_projects": { + "name": "v2_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_clone_url": { + "name": "repo_clone_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_projects_organization_id_idx": { + "name": "v2_projects_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_projects_organization_id_organizations_id_fk": { + "name": "v2_projects_organization_id_organizations_id_fk", + "tableFrom": "v2_projects", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_projects_github_repository_id_github_repositories_id_fk": { + "name": "v2_projects_github_repository_id_github_repositories_id_fk", + "tableFrom": "v2_projects", + "tableTo": "github_repositories", + "columnsFrom": [ + "github_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "v2_projects_org_slug_unique": { + "name": "v2_projects_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_users_hosts": { + "name": "v2_users_hosts", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "v2_users_host_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_users_hosts_organization_id_idx": { + "name": "v2_users_hosts_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_user_id_idx": { + "name": "v2_users_hosts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_users_hosts_host_id_idx": { + "name": "v2_users_hosts_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_users_hosts_organization_id_organizations_id_fk": { + "name": "v2_users_hosts_organization_id_organizations_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_user_id_users_id_fk": { + "name": "v2_users_hosts_user_id_users_id_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_users_hosts_host_fk": { + "name": "v2_users_hosts_host_fk", + "tableFrom": "v2_users_hosts", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "v2_users_hosts_organization_id_user_id_host_id_pk": { + "name": "v2_users_hosts_organization_id_user_id_host_id_pk", + "columns": [ + "organization_id", + "user_id", + "host_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.v2_workspaces": { + "name": "v2_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "v2_workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'worktree'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "v2_workspaces_project_id_idx": { + "name": "v2_workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_organization_id_idx": { + "name": "v2_workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_host_id_idx": { + "name": "v2_workspaces_host_id_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_task_id_idx": { + "name": "v2_workspaces_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "v2_workspaces_one_main_per_host": { + "name": "v2_workspaces_one_main_per_host", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"v2_workspaces\".\"type\" = 'main'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "v2_workspaces_organization_id_organizations_id_fk": { + "name": "v2_workspaces_organization_id_organizations_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "v2_workspaces_created_by_user_id_users_id_fk": { + "name": "v2_workspaces_created_by_user_id_users_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "v2_workspaces_task_id_tasks_id_fk": { + "name": "v2_workspaces_task_id_tasks_id_fk", + "tableFrom": "v2_workspaces", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "v2_workspaces_host_fk": { + "name": "v2_workspaces_host_fk", + "tableFrom": "v2_workspaces", + "tableTo": "v2_hosts", + "columnsFrom": [ + "organization_id", + "host_id" + ], + "columnsTo": [ + "organization_id", + "machine_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_pages": { + "name": "workspace_pages", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entry_path": { + "name": "entry_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_pages_workspace_id_entry_path_unique": { + "name": "workspace_pages_workspace_id_entry_path_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entry_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_pages_page_id_idx": { + "name": "workspace_pages_page_id_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_pages_page_id_pages_id_fk": { + "name": "workspace_pages_page_id_pages_id_fk", + "tableFrom": "workspace_pages", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_pages_workspace_id_page_id_pk": { + "name": "workspace_pages_workspace_id_page_id_pk", + "columns": [ + "workspace_id", + "page_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "workspace_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_organization_id_idx": { + "name": "workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_type_idx": { + "name": "workspaces_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_created_by_user_id_users_id_fk": { + "name": "workspaces_created_by_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_thread_sessions": { + "name": "slack_thread_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "quiet": { + "name": "quiet", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stop_requested_at": { + "name": "stop_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_context_ts": { + "name": "last_context_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_log": { + "name": "entity_log", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "queued_events": { + "name": "queued_events", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "slack_thread_sessions_organization_id_organizations_id_fk": { + "name": "slack_thread_sessions_organization_id_organizations_id_fk", + "tableFrom": "slack_thread_sessions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_thread_sessions_started_by_user_id_users_id_fk": { + "name": "slack_thread_sessions_started_by_user_id_users_id_fk", + "tableFrom": "slack_thread_sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "started_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_thread_sessions_thread_unique": { + "name": "slack_thread_sessions_thread_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "team_id", + "channel_id", + "thread_ts" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.handle_owner_type": { + "name": "handle_owner_type", + "schema": "public", + "values": [ + "user", + "organization", + "reserved" + ] + }, + "public.leaderboard_visibility": { + "name": "leaderboard_visibility", + "schema": "public", + "values": [ + "public", + "hidden" + ] + }, + "public.agent_credential_kind": { + "name": "agent_credential_kind", + "schema": "public", + "values": [ + "subscription", + "api_key" + ] + }, + "public.attachment_parent_kind": { + "name": "attachment_parent_kind", + "schema": "public", + "values": [ + "page_version", + "issue", + "doc", + "chat_session", + "comment", + "page" + ] + }, + "public.automation_prompt_source": { + "name": "automation_prompt_source", + "schema": "public", + "values": [ + "human", + "agent", + "restore" + ] + }, + "public.automation_run_error_code": { + "name": "automation_run_error_code", + "schema": "public", + "values": [ + "no_instructions", + "host_offline", + "agent_not_found", + "workspace_not_found" + ] + }, + "public.automation_run_status": { + "name": "automation_run_status", + "schema": "public", + "values": [ + "dispatching", + "dispatched", + "skipped_offline", + "dispatch_failed", + "debounced", + "rejected" + ] + }, + "public.automation_session_kind": { + "name": "automation_session_kind", + "schema": "public", + "values": [ + "chat", + "terminal" + ] + }, + "public.automation_trigger_kind": { + "name": "automation_trigger_kind", + "schema": "public", + "values": [ + "schedule", + "webhook", + "github", + "slack", + "linear", + "sentry", + "microsoft_teams", + "google_calendar", + "gmail", + "notion" + ] + }, + "public.cloud_workspace_status": { + "name": "cloud_workspace_status", + "schema": "public", + "values": [ + "provisioning", + "ready", + "failed", + "deleted" + ] + }, + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "completed", + "failed", + "timeout" + ] + }, + "public.desktop_notice_cta_action": { + "name": "desktop_notice_cta_action", + "schema": "public", + "values": [ + "install-update", + "open-url" + ] + }, + "public.desktop_notice_severity": { + "name": "desktop_notice_severity", + "schema": "public", + "values": [ + "info", + "warning", + "blocking" + ] + }, + "public.desktop_notice_trigger": { + "name": "desktop_notice_trigger", + "schema": "public", + "values": [ + "immediate", + "pre-update", + "post-update" + ] + }, + "public.environment_scope": { + "name": "environment_scope", + "schema": "public", + "values": [ + "organization", + "personal" + ] + }, + "public.environment_source_kind": { + "name": "environment_source_kind", + "schema": "public", + "values": [ + "image", + "fork" + ] + }, + "public.file_status": { + "name": "file_status", + "schema": "public", + "values": [ + "pending", + "ready" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github", + "slack", + "sentry", + "microsoft_teams", + "google", + "notion" + ] + }, + "public.page_comment_anchor_kind": { + "name": "page_comment_anchor_kind", + "schema": "public", + "values": [ + "element", + "text", + "page" + ] + }, + "public.page_comment_author_kind": { + "name": "page_comment_author_kind", + "schema": "public", + "values": [ + "human", + "agent" + ] + }, + "public.page_comment_intent": { + "name": "page_comment_intent", + "schema": "public", + "values": [ + "delete", + "approve" + ] + }, + "public.page_visibility": { + "name": "page_visibility", + "schema": "public", + "values": [ + "just_me", + "org", + "everyone" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + }, + "public.v2_client_type": { + "name": "v2_client_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.v2_users_host_role": { + "name": "v2_users_host_role", + "schema": "public", + "values": [ + "owner", + "member" + ] + }, + "public.v2_workspace_type": { + "name": "v2_workspace_type", + "schema": "public", + "values": [ + "main", + "worktree" + ] + }, + "public.workspace_type": { + "name": "workspace_type", + "schema": "public", + "values": [ + "local", + "cloud" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index b1dc74be227..4444d082a1f 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -841,6 +841,13 @@ "when": 1789625882184, "tag": "0119_plugin_oauth_clients_and_connectors", "breakpoints": true + }, + { + "idx": 120, + "version": "7", + "when": 1790129403162, + "tag": "0120_pages_created_at_keyset_index", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index a595483be72..2fcf822df61 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -1469,6 +1469,13 @@ export const pages = pgTable( table.organizationId, desc(table.updatedAt), ), + // The list keyset orders by (created_at, id). `updated_at` cannot serve it: + // the 30s watch heartbeat bumps it, which moves a row across an open cursor. + index("pages_organization_id_created_at_id_idx").on( + table.organizationId, + desc(table.createdAt), + desc(table.id), + ), index("pages_created_by_user_id_idx").on(table.createdByUserId), ], ); diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index 7fe9c10df9c..0f92e6534ed 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -4266,6 +4266,9 @@ msgstr "Nepodařilo se načíst prostředí." msgid "Could not load image" msgstr "Obrázek se nepodařilo načíst" +msgid "Could not load more pages." +msgstr "Další stránky se nepodařilo načíst." + msgid "Could not load plugins. Check your connection and try again." msgstr "Pluginy se nepodařilo načíst. Zkontrolujte připojení a zkuste to znovu." @@ -8456,6 +8459,9 @@ msgstr "Načítám issues…" msgid "Loading members..." msgstr "Načítám členy..." +msgid "Loading more pages…" +msgstr "Načítání dalších stránek…" + msgid "Loading more..." msgstr "Načítám další..." @@ -13892,9 +13898,6 @@ msgstr "Některé soubory překračují maximální velikost a nebyly přidány. msgid "Some models were priced with a fallback rate" msgstr "U některých modelů byla použita náhradní sazba" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Některé stránky se nepodařilo načíst, takže tento seznam je neúplný." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Některé repozitáře se nepodařilo načíst: {0}" diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index 8c862fcded0..f0489325368 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -4266,6 +4266,9 @@ msgstr "Umgebungen konnten nicht geladen werden." msgid "Could not load image" msgstr "Bild konnte nicht geladen werden" +msgid "Could not load more pages." +msgstr "Weitere Seiten konnten nicht geladen werden." + msgid "Could not load plugins. Check your connection and try again." msgstr "Plugins konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut." @@ -8456,6 +8459,9 @@ msgstr "Issues werden geladen…" msgid "Loading members..." msgstr "Mitglieder werden geladen..." +msgid "Loading more pages…" +msgstr "Weitere Seiten werden geladen…" + msgid "Loading more..." msgstr "Mehr wird geladen..." @@ -13892,9 +13898,6 @@ msgstr "Einige Dateien überschreiten die maximale Größe und wurden nicht hinz msgid "Some models were priced with a fallback rate" msgstr "Einige Modelle wurden mit einem Ersatzpreis berechnet" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Einige Seiten konnten nicht geladen werden, daher ist diese Liste unvollständig." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Einige Repositories konnten nicht geladen werden: {0}" diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index 0067cd19792..3fe83953833 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -4266,6 +4266,9 @@ msgstr "Could not load environments." msgid "Could not load image" msgstr "Could not load image" +msgid "Could not load more pages." +msgstr "Could not load more pages." + msgid "Could not load plugins. Check your connection and try again." msgstr "Could not load plugins. Check your connection and try again." @@ -8456,6 +8459,9 @@ msgstr "Loading issues…" msgid "Loading members..." msgstr "Loading members..." +msgid "Loading more pages…" +msgstr "Loading more pages…" + msgid "Loading more..." msgstr "Loading more..." @@ -13892,9 +13898,6 @@ msgstr "Some files exceed the maximum size and were not added." msgid "Some models were priced with a fallback rate" msgstr "Some models were priced with a fallback rate" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Some pages couldn't load, so this list is incomplete." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Some repositories could not be loaded: {0}" diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index 8b5d0a70937..144c88b4773 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -4266,6 +4266,9 @@ msgstr "No se pudieron cargar los entornos." msgid "Could not load image" msgstr "No se pudo cargar la imagen" +msgid "Could not load more pages." +msgstr "No se pudieron cargar más páginas." + msgid "Could not load plugins. Check your connection and try again." msgstr "No se pudieron cargar los plugins. Comprueba tu conexión e inténtalo de nuevo." @@ -8456,6 +8459,9 @@ msgstr "Cargando issues…" msgid "Loading members..." msgstr "Cargando miembros..." +msgid "Loading more pages…" +msgstr "Cargando más páginas…" + msgid "Loading more..." msgstr "Cargando más..." @@ -13892,9 +13898,6 @@ msgstr "Algunos archivos superan el tamaño máximo y no se añadieron." msgid "Some models were priced with a fallback rate" msgstr "Algunos modelos se tarifaron con una tarifa de respaldo" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "No se pudieron cargar algunas páginas, por lo que esta lista está incompleta." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "No se pudieron cargar algunos repositorios: {0}" diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index fcb2e6cb6ea..332b6dc8e01 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -4266,6 +4266,9 @@ msgstr "Impossible de charger les environnements." msgid "Could not load image" msgstr "Impossible de charger l'image" +msgid "Could not load more pages." +msgstr "Impossible de charger d'autres pages." + msgid "Could not load plugins. Check your connection and try again." msgstr "Impossible de charger les plugins. Vérifiez votre connexion et réessayez." @@ -8456,6 +8459,9 @@ msgstr "Chargement des issues…" msgid "Loading members..." msgstr "Chargement des membres..." +msgid "Loading more pages…" +msgstr "Chargement d'autres pages…" + msgid "Loading more..." msgstr "Chargement..." @@ -13892,9 +13898,6 @@ msgstr "Certains fichiers dépassent la taille maximale et n'ont pas été ajout msgid "Some models were priced with a fallback rate" msgstr "Certains modèles ont été valorisés à un tarif de repli" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Certaines pages n'ont pas pu être chargées, cette liste est donc incomplète." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Certains dépôts n'ont pas pu être chargés : {0}" diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index d074012a452..e735e5814d7 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -4266,6 +4266,9 @@ msgstr "Tidak dapat memuat environment." msgid "Could not load image" msgstr "Gambar tidak dapat dimuat" +msgid "Could not load more pages." +msgstr "Tidak dapat memuat halaman lainnya." + msgid "Could not load plugins. Check your connection and try again." msgstr "Tidak dapat memuat plugin. Periksa koneksi Anda dan coba lagi." @@ -8456,6 +8459,9 @@ msgstr "Memuat issue…" msgid "Loading members..." msgstr "Memuat anggota..." +msgid "Loading more pages…" +msgstr "Memuat halaman lainnya…" + msgid "Loading more..." msgstr "Memuat lagi..." @@ -13892,9 +13898,6 @@ msgstr "Sebagian file melebihi ukuran maksimum dan tidak ditambahkan." msgid "Some models were priced with a fallback rate" msgstr "Sebagian model dihitung dengan tarif cadangan" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Beberapa halaman gagal dimuat, sehingga daftar ini tidak lengkap." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Sebagian repositori tidak bisa dimuat: {0}" diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index d95c0737e9c..1eac2b9136c 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -4266,6 +4266,9 @@ msgstr "Impossibile caricare gli ambienti." msgid "Could not load image" msgstr "Impossibile caricare l'immagine" +msgid "Could not load more pages." +msgstr "Non è stato possibile caricare altre pagine." + msgid "Could not load plugins. Check your connection and try again." msgstr "Impossibile caricare i plugin. Controlla la connessione e riprova." @@ -8456,6 +8459,9 @@ msgstr "Caricamento delle issue…" msgid "Loading members..." msgstr "Caricamento dei membri..." +msgid "Loading more pages…" +msgstr "Caricamento di altre pagine…" + msgid "Loading more..." msgstr "Caricamento di altri..." @@ -13892,9 +13898,6 @@ msgstr "Alcuni file superano la dimensione massima e non sono stati aggiunti." msgid "Some models were priced with a fallback rate" msgstr "Alcuni modelli sono stati valutati con una tariffa di ripiego" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Non è stato possibile caricare alcune pagine, quindi questo elenco è incompleto." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Non è stato possibile caricare alcuni repository: {0}" diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index e72739fcf5b..f39a66df72a 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -4266,6 +4266,9 @@ msgstr "環境を読み込めませんでした。" msgid "Could not load image" msgstr "画像を読み込めませんでした" +msgid "Could not load more pages." +msgstr "これ以上ページを読み込めませんでした。" + msgid "Could not load plugins. Check your connection and try again." msgstr "プラグインを読み込めませんでした。接続を確認して再試行してください。" @@ -8456,6 +8459,9 @@ msgstr "Issueを読み込み中…" msgid "Loading members..." msgstr "メンバーを読み込み中..." +msgid "Loading more pages…" +msgstr "さらにページを読み込み中…" + msgid "Loading more..." msgstr "さらに読み込み中..." @@ -13892,9 +13898,6 @@ msgstr "一部のファイルは最大サイズを超えているため追加さ msgid "Some models were priced with a fallback rate" msgstr "一部のモデルはフォールバックのレートで計算しています" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "一部のページを読み込めなかったため、この一覧は不完全です。" - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "一部のリポジトリを読み込めませんでした: {0}" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index 68790a691fd..d253ad2cdf0 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -4266,6 +4266,9 @@ msgstr "환경을 불러오지 못했습니다." msgid "Could not load image" msgstr "이미지를 불러올 수 없음" +msgid "Could not load more pages." +msgstr "페이지를 더 불러올 수 없습니다." + msgid "Could not load plugins. Check your connection and try again." msgstr "플러그인을 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요." @@ -8456,6 +8459,9 @@ msgstr "이슈 불러오는 중…" msgid "Loading members..." msgstr "멤버 불러오는 중..." +msgid "Loading more pages…" +msgstr "페이지를 더 불러오는 중…" + msgid "Loading more..." msgstr "더 불러오는 중..." @@ -13892,9 +13898,6 @@ msgstr "일부 파일이 최대 크기를 초과해 추가되지 않았습니다 msgid "Some models were priced with a fallback rate" msgstr "일부 모델은 대체 단가로 계산했습니다" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "일부 페이지를 불러오지 못해 이 목록은 불완전합니다." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "일부 리포지토리를 불러오지 못했습니다: {0}" diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index c2e405317ae..1e4ca015b83 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -4266,6 +4266,9 @@ msgstr "Kon omgevingen niet laden." msgid "Could not load image" msgstr "Afbeelding kon niet worden geladen" +msgid "Could not load more pages." +msgstr "Kon geen pagina's meer laden." + msgid "Could not load plugins. Check your connection and try again." msgstr "Plugins konden niet worden geladen. Controleer je verbinding en probeer het opnieuw." @@ -8456,6 +8459,9 @@ msgstr "Issues laden…" msgid "Loading members..." msgstr "Leden laden..." +msgid "Loading more pages…" +msgstr "Meer pagina's laden…" + msgid "Loading more..." msgstr "Meer laden..." @@ -13892,9 +13898,6 @@ msgstr "Sommige bestanden zijn groter dan de maximale grootte en zijn niet toege msgid "Some models were priced with a fallback rate" msgstr "Voor sommige modellen is een terugvaltarief gebruikt" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Sommige pagina's konden niet worden geladen, dus deze lijst is onvolledig." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Sommige repository's konden niet worden geladen: {0}" diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index b6535e03ab1..4c4385bb344 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -4266,6 +4266,9 @@ msgstr "Nie udało się wczytać środowisk." msgid "Could not load image" msgstr "Nie udało się załadować obrazu" +msgid "Could not load more pages." +msgstr "Nie udało się wczytać kolejnych stron." + msgid "Could not load plugins. Check your connection and try again." msgstr "Nie udało się wczytać wtyczek. Sprawdź połączenie i spróbuj ponownie." @@ -8456,6 +8459,9 @@ msgstr "Wczytywanie zgłoszeń…" msgid "Loading members..." msgstr "Wczytywanie członków..." +msgid "Loading more pages…" +msgstr "Wczytywanie kolejnych stron…" + msgid "Loading more..." msgstr "Wczytywanie kolejnych..." @@ -13892,9 +13898,6 @@ msgstr "Część plików przekracza maksymalny rozmiar i nie została dodana." msgid "Some models were priced with a fallback rate" msgstr "Część modeli wyceniono stawką zastępczą" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Nie udało się załadować niektórych stron, więc ta lista jest niekompletna." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Nie udało się wczytać niektórych repozytoriów: {0}" diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index 2706cfceb41..eae9b813b4c 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -4266,6 +4266,9 @@ msgstr "Não foi possível carregar os ambientes." msgid "Could not load image" msgstr "Não foi possível carregar a imagem" +msgid "Could not load more pages." +msgstr "Não foi possível carregar mais páginas." + msgid "Could not load plugins. Check your connection and try again." msgstr "Não foi possível carregar os plugins. Verifique sua conexão e tente novamente." @@ -8456,6 +8459,9 @@ msgstr "Carregando as issues…" msgid "Loading members..." msgstr "Carregando membros..." +msgid "Loading more pages…" +msgstr "Carregando mais páginas…" + msgid "Loading more..." msgstr "Carregando mais..." @@ -13892,9 +13898,6 @@ msgstr "Alguns arquivos passam do tamanho máximo e não foram adicionados." msgid "Some models were priced with a fallback rate" msgstr "Alguns modelos foram precificados com uma taxa de reserva" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Não foi possível carregar algumas páginas, então esta lista está incompleta." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Alguns repositórios não puderam ser carregados: {0}" diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index d34a026e794..e3f5b8d25ba 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -4266,6 +4266,9 @@ msgstr "Не удалось загрузить окружения." msgid "Could not load image" msgstr "Не удалось загрузить изображение" +msgid "Could not load more pages." +msgstr "Не удалось загрузить другие страницы." + msgid "Could not load plugins. Check your connection and try again." msgstr "Не удалось загрузить плагины. Проверьте подключение и попробуйте снова." @@ -8456,6 +8459,9 @@ msgstr "Загрузка задач…" msgid "Loading members..." msgstr "Загрузка участников..." +msgid "Loading more pages…" +msgstr "Загрузка других страниц…" + msgid "Loading more..." msgstr "Загружаем ещё..." @@ -13892,9 +13898,6 @@ msgstr "Некоторые файлы превышают максимальны msgid "Some models were priced with a fallback rate" msgstr "Для части моделей использована резервная ставка тарификации" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Некоторые страницы не удалось загрузить, поэтому этот список неполный." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Некоторые репозитории не удалось загрузить: {0}" diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index cde20407bb2..c74459297fa 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -4266,6 +4266,9 @@ msgstr "Ortamlar yüklenemedi." msgid "Could not load image" msgstr "Görsel yüklenemedi" +msgid "Could not load more pages." +msgstr "Daha fazla sayfa yüklenemedi." + msgid "Could not load plugins. Check your connection and try again." msgstr "Eklentiler yüklenemedi. Bağlantınızı kontrol edip tekrar deneyin." @@ -8456,6 +8459,9 @@ msgstr "Issue'lar yükleniyor…" msgid "Loading members..." msgstr "Üyeler yükleniyor..." +msgid "Loading more pages…" +msgstr "Daha fazla sayfa yükleniyor…" + msgid "Loading more..." msgstr "Daha fazlası yükleniyor..." @@ -13892,9 +13898,6 @@ msgstr "Bazı dosyalar en büyük boyutu aşıyor ve eklenmedi." msgid "Some models were priced with a fallback rate" msgstr "Bazı modeller yedek bir tarifeyle fiyatlandırıldı" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Bazı sayfalar yüklenemedi, bu nedenle bu liste eksik." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Bazı depolar yüklenemedi: {0}" diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 7dacbe029cc..afdef69e523 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -4266,6 +4266,9 @@ msgstr "Không thể tải environments." msgid "Could not load image" msgstr "Không thể tải ảnh" +msgid "Could not load more pages." +msgstr "Không thể tải thêm trang." + msgid "Could not load plugins. Check your connection and try again." msgstr "Không tải được plugin. Hãy kiểm tra kết nối và thử lại." @@ -8456,6 +8459,9 @@ msgstr "Đang tải issue…" msgid "Loading members..." msgstr "Đang tải thành viên..." +msgid "Loading more pages…" +msgstr "Đang tải thêm trang…" + msgid "Loading more..." msgstr "Đang tải thêm..." @@ -13892,9 +13898,6 @@ msgstr "Một số tệp vượt quá kích thước tối đa nên không đư msgid "Some models were priced with a fallback rate" msgstr "Một số mô hình được tính giá theo mức dự phòng" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "Không thể tải một số trang nên danh sách này chưa đầy đủ." - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "Một số repository không tải được: {0}" diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index b7d01ad1646..81c58b2a5b1 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -4266,6 +4266,9 @@ msgstr "无法加载环境。" msgid "Could not load image" msgstr "无法加载图片" +msgid "Could not load more pages." +msgstr "无法加载更多页面。" + msgid "Could not load plugins. Check your connection and try again." msgstr "无法加载插件。请检查网络连接后重试。" @@ -8456,6 +8459,9 @@ msgstr "正在加载议题…" msgid "Loading members..." msgstr "正在加载成员..." +msgid "Loading more pages…" +msgstr "正在加载更多页面…" + msgid "Loading more..." msgstr "正在加载更多..." @@ -13892,9 +13898,6 @@ msgstr "部分文件超过大小上限,未添加。" msgid "Some models were priced with a fallback rate" msgstr "部分模型按兜底费率计价" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "部分页面加载失败,因此此列表不完整。" - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "部分仓库无法加载:{0}" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index 8c9544decda..0327353fe1b 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -4266,6 +4266,9 @@ msgstr "無法載入環境。" msgid "Could not load image" msgstr "無法載入圖片" +msgid "Could not load more pages." +msgstr "無法載入更多頁面。" + msgid "Could not load plugins. Check your connection and try again." msgstr "無法載入外掛。請檢查連線後再試一次。" @@ -8456,6 +8459,9 @@ msgstr "正在載入議題…" msgid "Loading members..." msgstr "正在載入成員..." +msgid "Loading more pages…" +msgstr "正在載入更多頁面…" + msgid "Loading more..." msgstr "正在載入更多..." @@ -13892,9 +13898,6 @@ msgstr "部分檔案超過大小上限,未新增。" msgid "Some models were priced with a fallback rate" msgstr "部分模型按備用費率計價" -msgid "Some pages couldn't load, so this list is incomplete." -msgstr "部分頁面載入失敗,因此這份清單並不完整。" - #. placeholder {0}: error.message msgid "Some repositories could not be loaded: {0}" msgstr "部分儲存庫無法載入:{0}" diff --git a/packages/mcp/src/tools/pages/list.ts b/packages/mcp/src/tools/pages/list.ts index 90ad3202afc..149e6b0deec 100644 --- a/packages/mcp/src/tools/pages/list.ts +++ b/packages/mcp/src/tools/pages/list.ts @@ -2,7 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { PAGE_LIST_DEFAULT_LIMIT, PAGE_LIST_MAX_LIMIT, - pageListCursorSchema, + PAGE_LIST_SCOPES, } from "@superset/trpc/page-schema"; import { z } from "zod"; import { createMcpCaller } from "../../caller"; @@ -13,7 +13,7 @@ export function register(server: McpServer): void { name: "pages_list", annotations: { readOnlyHint: true }, description: - "List published pages in the active organization, newest first. A page is one self-contained HTML file published to a shareable URL. Returns only pages the caller may read: everything shared with the organization, plus the caller's own private ones. Use this to find a page's id or slug before calling any other pages_* tool. Results are paginated: pass `search` to narrow by title or slug, and when `nextCursor` comes back non-null pass it as `cursor` to get the next batch.", + "List published pages in the active organization, newest-created first. A page is one self-contained HTML file published to a shareable URL. Returns only pages the caller may read: everything shared with the organization, plus the caller's own private ones. Use this to find a page's id or slug before calling any other pages_* tool. Ordering is by creation, so republishing a page does not move it. Results are paginated: pass `search` to narrow by title, slug or description, and when `nextCursor` comes back non-null pass it as `cursor` to get the next batch.", inputSchema: { workspaceId: z .string() @@ -28,7 +28,7 @@ export function register(server: McpServer): void { .max(200) .nullish() .describe( - "Only pages whose title or slug contains this text, case-insensitive.", + "Only pages whose title, slug or description contains this text, case-insensitive.", ), limit: z .number() @@ -39,10 +39,18 @@ export function register(server: McpServer): void { .describe( `How many pages to return, 1-${PAGE_LIST_MAX_LIMIT} (default ${PAGE_LIST_DEFAULT_LIMIT}).`, ), - cursor: pageListCursorSchema + scope: z + .enum(PAGE_LIST_SCOPES) .nullish() .describe( - "The `nextCursor` object from a previous call, passed back unchanged, to fetch the next batch. Omit for the first batch.", + "`team` for pages shared with the organization, `mine` for the caller's private ones, `all` for both (default).", + ), + cursor: z + .string() + .max(256) + .nullish() + .describe( + "The `nextCursor` string from a previous call, passed back unchanged, to fetch the next batch. Omit for the first batch. Treat it as opaque.", ), }, handler: async (input, ctx) => { @@ -50,6 +58,7 @@ export function register(server: McpServer): void { return caller.page.list({ ...(input?.workspaceId ? { workspaceId: input.workspaceId } : {}), ...(input?.search ? { search: input.search } : {}), + ...(input?.scope ? { scope: input.scope } : {}), ...(input?.limit ? { limit: input.limit } : {}), ...(input?.cursor ? { cursor: input.cursor } : {}), }); diff --git a/packages/trpc/src/router/page/cursor.test.ts b/packages/trpc/src/router/page/cursor.test.ts new file mode 100644 index 00000000000..e8bb1d2c455 --- /dev/null +++ b/packages/trpc/src/router/page/cursor.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { decodePageCursor, encodePageCursor } from "./cursor"; + +const PAGE = "00000000-0000-4000-8000-000000000002"; +const CREATED_AT = "2026-09-14 10:00:00.123456+00"; + +describe("page list cursor", () => { + test("survives a round trip", () => { + const encoded = encodePageCursor({ createdAt: CREATED_AT, id: PAGE }); + expect(decodePageCursor(encoded)).toEqual({ + createdAt: CREATED_AT, + id: PAGE, + }); + }); + + test("keeps the sub-millisecond digits Postgres reported", () => { + const decoded = decodePageCursor( + encodePageCursor({ createdAt: CREATED_AT, id: PAGE }), + ); + expect(decoded?.createdAt).toContain(".123456"); + }); + + test("encodes without characters a shell or URL would mangle", () => { + const encoded = encodePageCursor({ createdAt: CREATED_AT, id: PAGE }); + expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + test("rejects a typo'd cursor rather than crashing on the parse", () => { + expect(decodePageCursor("not-a-cursor")).toBeNull(); + }); + + test("rejects a well-formed token missing the id", () => { + const encoded = Buffer.from( + JSON.stringify({ createdAt: CREATED_AT }), + ).toString("base64url"); + expect(decodePageCursor(encoded)).toBeNull(); + }); + + test("rejects a token whose timestamp is not a string", () => { + const encoded = Buffer.from( + JSON.stringify({ createdAt: 12345, id: PAGE }), + ).toString("base64url"); + expect(decodePageCursor(encoded)).toBeNull(); + }); + + test("rejects a timestamp that is not a Postgres timestamptz", () => { + // This value reaches the query as `::timestamptz`, so the shape is + // checked rather than trusted from whatever the caller echoed back. + const encoded = Buffer.from( + JSON.stringify({ + createdAt: "2026-09-14T10:00:00Z'; drop table", + id: PAGE, + }), + ).toString("base64url"); + expect(decodePageCursor(encoded)).toBeNull(); + }); + + test("accepts the offsets Postgres emits, whole-hour and half-hour alike", () => { + for (const createdAt of [ + "2026-09-14 10:00:00+00", + "2026-09-14 10:00:00.123456+05:30", + "2026-09-14 10:00:00.1-08", + ]) { + const encoded = Buffer.from( + JSON.stringify({ createdAt, id: PAGE }), + ).toString("base64url"); + expect(decodePageCursor(encoded)).toEqual({ createdAt, id: PAGE }); + } + }); + + test("rejects an id that is not a uuid", () => { + const encoded = Buffer.from( + JSON.stringify({ createdAt: CREATED_AT, id: "not-a-uuid" }), + ).toString("base64url"); + expect(decodePageCursor(encoded)).toBeNull(); + }); +}); diff --git a/packages/trpc/src/router/page/cursor.ts b/packages/trpc/src/router/page/cursor.ts new file mode 100644 index 00000000000..9ae1cec9cb9 --- /dev/null +++ b/packages/trpc/src/router/page/cursor.ts @@ -0,0 +1,41 @@ +/** + * The list cursor is opaque to every caller: the CLI passes it back as a + * string, and the MCP tool schema describes it as one. Keeping the keyset + * columns out of the public shape is what lets the ordering change without + * breaking a client, which the move from `updated_at` to `created_at` needed. + */ +export interface PageListKeyset { + createdAt: string; + id: string; +} + +const POSTGRES_TIMESTAMPTZ_TEXT = + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,6})?[+-]\d{2}(:\d{2}(:\d{2})?)?$/; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function encodePageCursor(keyset: PageListKeyset): string { + return Buffer.from( + JSON.stringify({ createdAt: keyset.createdAt, id: keyset.id }), + ).toString("base64url"); +} + +export function decodePageCursor(value: string): PageListKeyset | null { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + } catch { + return null; + } + + const keyset = parsed as Partial | null; + if (typeof keyset?.createdAt !== "string" || typeof keyset.id !== "string") { + return null; + } + // `createdAt` is interpolated into the query as `::timestamptz`, so it is + // checked here rather than trusted from whatever the caller echoed back. + if (!POSTGRES_TIMESTAMPTZ_TEXT.test(keyset.createdAt)) return null; + if (!UUID.test(keyset.id)) return null; + + return { createdAt: keyset.createdAt, id: keyset.id }; +} diff --git a/packages/trpc/src/router/page/list.integration.ts b/packages/trpc/src/router/page/list.integration.ts new file mode 100644 index 00000000000..16b780cba21 --- /dev/null +++ b/packages/trpc/src/router/page/list.integration.ts @@ -0,0 +1,264 @@ +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; + +mock.module("../../lib/r2", () => ({ + presignedGetUrl: async (key: string) => `https://storage.test/${key}`, + presignedPutUrl: async ({ key }: { key: string }) => ({ + url: `https://storage.test/${key}`, + headers: {}, + }), + putObject: async () => {}, + getObject: async () => null, + headObject: async () => null, + copyObject: async () => {}, + deleteObjects: async () => {}, + objectExists: async () => true, +})); + +const { db, dbWs } = await import("@superset/db/client"); +const { members, organizations, pages, users, workspacePages } = await import( + "@superset/db/schema" +); +const { and, eq, ne, or } = await import("drizzle-orm"); +const { pageRouter } = await import("./page"); +const { createCallerFactory, createTRPCContext, createTRPCRouter } = + await import("../../trpc"); + +const ORG = crypto.randomUUID(); +const USER = crypto.randomUUID(); +const OTHER_USER = crypto.randomUUID(); +const WORKSPACE = crypto.randomUUID(); +const suffix = Date.now(); + +const callerFor = (userId: string) => + createCallerFactory(createTRPCRouter({ page: pageRouter }))( + createTRPCContext({ + session: { + user: { id: userId, email: `u-${userId}@example.com` }, + session: { activeOrganizationId: ORG }, + } as never, + auth: {} as never, + headers: new Headers(), + }), + ); + +const caller = callerFor(USER); + +const TOTAL = 463; + +/** Walks the whole list through the cursor, the way every client does. */ +async function walk( + input: Parameters[0] = {}, + onBatch?: (batch: number) => Promise, + limit = 200, +): Promise { + const seen: string[] = []; + let cursor: string | undefined; + let batch = 0; + do { + const result = await caller.page.list({ + ...input, + limit, + ...(cursor ? { cursor } : {}), + }); + seen.push(...result.items.map((item) => item.id)); + cursor = result.nextCursor ?? undefined; + batch += 1; + if (onBatch) await onBatch(batch); + } while (cursor); + return seen; +} + +beforeAll(async () => { + await db.insert(organizations).values({ + id: ORG, + name: "List Org", + slug: `list-org-${suffix}`, + }); + await db.insert(users).values([ + { + id: USER, + name: "List User", + email: `list-${suffix}@example.com`, + organizationIds: [ORG], + }, + { + id: OTHER_USER, + name: "Other User", + email: `list-other-${suffix}@example.com`, + organizationIds: [ORG], + }, + ]); + await db.insert(members).values([ + { + id: crypto.randomUUID(), + organizationId: ORG, + userId: USER, + role: "owner", + createdAt: new Date(), + }, + { + id: crypto.randomUUID(), + organizationId: ORG, + userId: OTHER_USER, + role: "member", + createdAt: new Date(), + }, + ]); + + // Every row shares one `created_at` second so the id half of the keyset is + // exercised rather than skipped by distinct timestamps. + const base = new Date("2026-01-01T00:00:00.000Z"); + await db.insert(pages).values( + Array.from({ length: TOTAL }, (_, i) => ({ + id: crypto.randomUUID(), + slug: `list-page-${suffix}-${i}`, + organizationId: ORG, + createdByUserId: i % 3 === 0 ? OTHER_USER : USER, + title: i % 5 === 0 ? `Report ${i}` : `Page ${i}`, + description: i % 7 === 0 ? "quarterly summary" : null, + visibility: i % 4 === 0 ? ("just_me" as const) : ("org" as const), + createdAt: new Date(base.getTime() + Math.floor(i / 20) * 1000), + })), + ); + + const [first] = await db + .select({ id: pages.id }) + .from(pages) + .where(eq(pages.organizationId, ORG)) + .limit(1); + if (first) { + await db.insert(workspacePages).values({ + pageId: first.id, + workspaceId: WORKSPACE, + entryPath: "/index.html", + }); + } +}); + +afterAll(async () => { + await db.delete(organizations).where(eq(organizations.id, ORG)); + await db.delete(users).where(eq(users.id, USER)); + await db.delete(users).where(eq(users.id, OTHER_USER)); + await dbWs.$client.end?.(); +}); + +describe("page.list keyset", () => { + test("a full walk returns every visible page exactly once", async () => { + const seen = await walk({}, undefined, 50); + const visible = await db + .select({ id: pages.id }) + .from(pages) + .where( + and( + eq(pages.organizationId, ORG), + or(ne(pages.visibility, "just_me"), eq(pages.createdByUserId, USER)), + ), + ); + + expect(new Set(seen).size).toBe(seen.length); + expect(seen.length).toBe(visible.length); + expect([...seen].sort()).toEqual(visible.map((row) => row.id).sort()); + }, 30_000); + + test("a watch heartbeat mid-walk does not drop a page", async () => { + // The bug this keyset replaced: `setWatch` bumps `updated_at` every 30s, + // which moved a row across an open cursor and skipped its neighbour. + const owned = await db + .select({ id: pages.id }) + .from(pages) + .where(eq(pages.createdByUserId, USER)); + + const bumped: string[] = []; + const seen = await walk( + {}, + async (batch) => { + const row = owned[batch * 7]; + if (!row) return; + await caller.page.setWatch({ id: row.id, agentId: "claude" }); + bumped.push(row.id); + }, + 50, + ); + + expect(bumped.length).toBeGreaterThan(0); + expect(new Set(seen).size).toBe(seen.length); + for (const id of bumped) expect(seen).toContain(id); + }, 30_000); + + test("an unreadable cursor is a BAD_REQUEST, not a crash", async () => { + await expect(caller.page.list({ cursor: "not-a-cursor" })).rejects.toThrow( + /cursor/i, + ); + }); +}); + +describe("page.list filters", () => { + test("scope mine and team partition the list", async () => { + const all = await walk({ scope: "all" }); + const mine = await walk({ scope: "mine" }); + const team = await walk({ scope: "team" }); + + expect(mine.length + team.length).toBe(all.length); + expect(mine.filter((id) => team.includes(id))).toEqual([]); + }, 30_000); + + test("search matches title, slug and description", async () => { + const byTitle = await walk({ search: "Report" }); + const byDescription = await walk({ search: "quarterly" }); + + expect(byTitle.length).toBeGreaterThan(0); + expect(byDescription.length).toBeGreaterThan(0); + }, 30_000); + + test("an empty search is the unfiltered list", async () => { + const blank = await caller.page.list({ search: " ", limit: 5 }); + const none = await caller.page.list({ limit: 5 }); + + expect(blank.items.map((i) => i.id)).toEqual(none.items.map((i) => i.id)); + }); + + test("ids narrows to the pinned set, and an empty ids returns nothing", async () => { + const first = await caller.page.list({ limit: 3 }); + const ids = first.items.map((item) => item.id); + + const pinned = await caller.page.list({ ids, limit: 50 }); + expect(pinned.items.map((item) => item.id).sort()).toEqual([...ids].sort()); + + const empty = await caller.page.list({ ids: [], limit: 50 }); + expect(empty.items).toEqual([]); + }); + + test("authorId narrows to that author", async () => { + const mine = await walk({ authorId: OTHER_USER }); + expect(mine.length).toBeGreaterThan(0); + }); +}); + +describe("page.counts", () => { + test("counts agree with what the list returns", async () => { + const counts = await caller.page.counts(); + const all = await walk({ scope: "all" }); + const mine = await walk({ scope: "mine" }); + const team = await walk({ scope: "team" }); + + expect(counts.all).toBe(all.length); + expect(counts.mine).toBe(mine.length); + expect(counts.team).toBe(team.length); + }, 30_000); + + test("pinned counts only the ids it is given", async () => { + const first = await caller.page.list({ limit: 4 }); + const ids = first.items.map((item) => item.id); + const counts = await caller.page.counts({ pinnedIds: ids }); + + expect(counts.pinned).toBe(ids.length); + expect((await caller.page.counts({ pinnedIds: [] })).pinned).toBe(0); + }); + + test("a search narrows the counts too", async () => { + const counts = await caller.page.counts({ search: "Report" }); + const listed = await walk({ search: "Report" }); + + expect(counts.all).toBe(listed.length); + }, 30_000); +}); diff --git a/packages/trpc/src/router/page/page.ts b/packages/trpc/src/router/page/page.ts index 05c45b54360..1a0fbff009c 100644 --- a/packages/trpc/src/router/page/page.ts +++ b/packages/trpc/src/router/page/page.ts @@ -25,6 +25,7 @@ import { eq, ilike, inArray, + isNotNull, lt, notExists, or, @@ -38,6 +39,7 @@ import { protectedProcedure, publicProcedure, userError } from "../../trpc"; import { requireActiveOrgMembership } from "../utils/active-org"; import { assertPageReadable, assertPageWritable } from "./access"; import { pageAssetRouter } from "./assets"; +import { decodePageCursor, encodePageCursor } from "./cursor"; import { pageUrl } from "./page-url"; import { publishPage } from "./publish"; import { isEntryPathConflict } from "./publish-rules"; @@ -47,6 +49,8 @@ import { deletePageSchema, listPagesSchema, PAGE_LIST_DEFAULT_LIMIT, + type PageListScope, + pageCountsSchema, pageFields, pageRefSchema, publicPageSchema, @@ -79,6 +83,49 @@ function visibilityFilter(userId: string) { ); } +function scopeFilter(scope: PageListScope): SQL | undefined { + switch (scope) { + case "team": + return sql`${pages.visibility} <> 'just_me'`; + case "mine": + return eq(pages.visibility, "just_me"); + default: + return undefined; + } +} + +/** + * Shared by `list` and `counts` so a tab's count and its contents can't be + * answered by two different WHERE clauses. + */ +function pageFilters(input: { + search?: string | undefined; + scope?: PageListScope | undefined; + authorId?: string | undefined; + ids?: string[] | undefined; +}): (SQL | undefined)[] { + const filters: (SQL | undefined)[] = []; + + if (input.search) { + const term = `%${escapeLikePattern(input.search)}%`; + filters.push( + or( + ilike(pages.title, term), + ilike(pages.slug, term), + ilike(pages.description, term), + ), + ); + } + + if (input.scope) filters.push(scopeFilter(input.scope)); + if (input.authorId) filters.push(eq(pages.createdByUserId, input.authorId)); + // An empty array is "no pins", which must return nothing rather than + // degrade to the unfiltered list. + if (input.ids) filters.push(inArray(pages.id, input.ids)); + + return filters; +} + async function pageNotFound(identity: SQL, userId: string): Promise { const [elsewhere] = await db .select({ organizationName: organizations.name }) @@ -287,7 +334,9 @@ export const pageRouter = { sharedVersion: pages.sharedVersion, createdAt: pages.createdAt, updatedAt: pages.updatedAt, - updatedAtCursor: sql`${pages.updatedAt}::text`, + // `::text` keeps the microseconds a JS Date would truncate, which + // the keyset comparison needs to be exact. + createdAtCursor: sql`${pages.createdAt}::text`, createdByUserId: pages.createdByUserId, ownerName: users.name, ownerImage: users.image, @@ -303,19 +352,24 @@ export const pageRouter = { const filters: (SQL | undefined)[] = [ eq(pages.organizationId, organizationId), visibilityFilter(userId), + ...pageFilters(input ?? {}), ]; - if (input?.search) { - const term = `%${escapeLikePattern(input.search)}%`; - filters.push(or(ilike(pages.title, term), ilike(pages.slug, term))); - } - if (input?.cursor) { - const at = sql`${input.cursor.updatedAt}::timestamptz`; + const keyset = decodePageCursor(input.cursor); + if (!keyset) { + throw userError({ + code: "BAD_REQUEST", + message: + "That cursor could not be read. Drop it to start from the first page.", + i18nKey: "serverError.page.cursorCouldNotBeRead", + }); + } + const at = sql`${keyset.createdAt}::timestamptz`; filters.push( or( - lt(pages.updatedAt, at), - and(eq(pages.updatedAt, at), lt(pages.id, input.cursor.id)), + lt(pages.createdAt, at), + and(eq(pages.createdAt, at), lt(pages.id, keyset.id)), ), ); } @@ -332,14 +386,14 @@ export const pageRouter = { : base.where(and(...filters)); const rows = await scoped - .orderBy(desc(pages.updatedAt), desc(pages.id)) + .orderBy(desc(pages.createdAt), desc(pages.id)) .limit(limit + 1); const pageRows = rows.slice(0, limit); const last = pageRows.at(-1); const nextCursor = rows.length > limit && last - ? { updatedAt: last.updatedAtCursor, id: last.id } + ? encodePageCursor({ createdAt: last.createdAtCursor, id: last.id }) : null; const links = pageRows.length @@ -371,7 +425,7 @@ export const pageRouter = { const baseUrl = env.USERCONTENT_URL; const items = await Promise.all( - pageRows.map(async ({ updatedAtCursor: _cursor, ...row }) => { + pageRows.map(async ({ createdAtCursor: _cursor, ...row }) => { const served = servedVersion(row.sharedVersion, row.latestVersion); const ticket = await mintPageTicket(row); // Version-bound, so it turns daily instead of hourly — the capture @@ -403,6 +457,85 @@ export const pageRouter = { return { items, nextCursor }; }), + counts: protectedProcedure + .input(pageCountsSchema) + .query(async ({ ctx, input }) => { + const organizationId = await requireActiveOrgMembership(ctx); + const userId = ctx.session.user.id; + + if (input?.workspaceId) { + await assertWorkspaceAccess({ + executor: db, + workspaceId: input.workspaceId, + organizationId, + }); + } + + const filters: (SQL | undefined)[] = [ + eq(pages.organizationId, organizationId), + visibilityFilter(userId), + ...pageFilters({ + search: input?.search, + authorId: input?.authorId, + }), + ]; + + const pinned = input?.pinnedIds ?? []; + const selection = { + all: sql`count(*)::int`, + team: sql`count(*) filter (where ${pages.visibility} <> 'just_me')::int`, + mine: sql`count(*) filter (where ${pages.visibility} = 'just_me')::int`, + pinned: pinned.length + ? sql`count(*) filter (where ${inArray(pages.id, pinned)})::int` + : sql`0::int`, + }; + + const query = db.select(selection).from(pages); + const [row] = await (input?.workspaceId + ? query + .innerJoin(workspacePages, eq(workspacePages.pageId, pages.id)) + .where( + and( + ...filters, + eq(workspacePages.workspaceId, input.workspaceId), + ), + ) + : query.where(and(...filters))); + + // The workspace picker's own counts, so choosing a workspace does not + // depend on having downloaded every page to count them. Deliberately + // not narrowed by `workspaceId` — that is the option being chosen. + const workspaces = await db + .select({ + workspaceId: workspacePages.workspaceId, + count: sql`count(*)::int`, + }) + .from(workspacePages) + .innerJoin(pages, eq(pages.id, workspacePages.pageId)) + .where(and(...filters)) + .groupBy(workspacePages.workspaceId); + + // Likewise the author picker: bounded by how many people have published, + // not by how many pages there are. + const authors = await db + .select({ + userId: pages.createdByUserId, + name: users.name, + image: users.image, + count: sql`count(*)::int`, + }) + .from(pages) + .leftJoin(users, eq(users.id, pages.createdByUserId)) + .where(and(...filters, isNotNull(pages.createdByUserId))) + .groupBy(pages.createdByUserId, users.name, users.image); + + return { + ...(row ?? { all: 0, team: 0, mine: 0, pinned: 0 }), + workspaces, + authors, + }; + }), + get: protectedProcedure.input(pageRefSchema).query(async ({ ctx, input }) => { const organizationId = await requireActiveOrgMembership(ctx); const page = await loadPage({ diff --git a/packages/trpc/src/router/page/schema.test.ts b/packages/trpc/src/router/page/schema.test.ts index c5fb9f90b5a..d1d017343cf 100644 --- a/packages/trpc/src/router/page/schema.test.ts +++ b/packages/trpc/src/router/page/schema.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { listPagesSchema, PAGE_LIST_DEFAULT_LIMIT, + PAGE_LIST_MAX_IDS, PAGE_LIST_MAX_LIMIT, publishPageSchema, } from "./schema"; @@ -99,40 +100,50 @@ describe("listPagesSchema", () => { ).toBe(false); }); - test("refuses an empty search rather than matching everything", () => { - expect(listPagesSchema.safeParse({ search: "" }).success).toBe(false); + test("treats an empty or blank search as no search, not as match-everything", () => { + expect(listPagesSchema.parse({ search: "" })?.search).toBeUndefined(); + expect(listPagesSchema.parse({ search: " " })?.search).toBeUndefined(); }); - test("keeps the cursor timestamp as Postgres reported it, microseconds and all", () => { - const result = listPagesSchema.parse({ - cursor: { updatedAt: "2026-09-14 10:00:00.123456+00", id: PAGE }, - }); - expect(result?.cursor?.updatedAt).toBe("2026-09-14 10:00:00.123456+00"); + test("trims a search so a stray space does not change the query", () => { + expect(listPagesSchema.parse({ search: " report " })?.search).toBe( + "report", + ); }); - test("refuses half a cursor", () => { - expect(listPagesSchema.safeParse({ cursor: { id: PAGE } }).success).toBe( - false, + test("takes the cursor as an opaque string", () => { + const cursor = Buffer.from("whatever the server emitted").toString( + "base64url", ); + expect(listPagesSchema.parse({ cursor })?.cursor).toBe(cursor); }); - test("refuses a timestamp Postgres would reject, rather than passing it to the cast", () => { + test("refuses a cursor that is not a string, so the keyset stays private", () => { expect( listPagesSchema.safeParse({ - cursor: { updatedAt: "not-a-timestamp", id: PAGE }, + cursor: { updatedAt: "2026-09-14 10:00:00+00", id: PAGE }, }).success, ).toBe(false); }); - test("accepts the offsets Postgres emits, whole-hour and half-hour alike", () => { - for (const updatedAt of [ - "2026-09-14 10:00:00+00", - "2026-09-14 10:00:00.123456+05:30", - "2026-09-14 10:00:00.1-08", - ]) { - expect( - listPagesSchema.safeParse({ cursor: { updatedAt, id: PAGE } }), - ).toMatchObject({ success: true }); - } + test("defaults scope to all and refuses one it does not filter on", () => { + expect(listPagesSchema.parse({})?.scope).toBe("all"); + expect(listPagesSchema.safeParse({ scope: "pinned" }).success).toBe(false); + }); + + test("accepts an ids filter up to the pin cap and refuses more", () => { + const ids = Array.from({ length: PAGE_LIST_MAX_IDS }, () => PAGE); + expect(listPagesSchema.safeParse({ ids }).success).toBe(true); + expect(listPagesSchema.safeParse({ ids: [...ids, PAGE] }).success).toBe( + false, + ); + }); + + test("accepts an empty ids filter — no pins is not no filter", () => { + expect(listPagesSchema.parse({ ids: [] })?.ids).toEqual([]); + }); + + test("refuses an authorId that is not a uuid", () => { + expect(listPagesSchema.safeParse({ authorId: "nope" }).success).toBe(false); }); }); diff --git a/packages/trpc/src/router/page/schema.ts b/packages/trpc/src/router/page/schema.ts index db8bdb6e21b..c6e69bff30c 100644 --- a/packages/trpc/src/router/page/schema.ts +++ b/packages/trpc/src/router/page/schema.ts @@ -91,19 +91,41 @@ export type CreatePageInput = z.infer; export const PAGE_LIST_DEFAULT_LIMIT = 50; export const PAGE_LIST_MAX_LIMIT = 200; -const POSTGRES_TIMESTAMPTZ_TEXT = - /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,6})?[+-]\d{2}(:\d{2}(:\d{2})?)?$/; +/** + * `MAX_FAVORITE_PAGE_IDS` on the desktop, which is where the only unbounded + * caller comes from: pins live in renderer storage, so the pinned tab asks for + * them by id rather than by a column the server could filter on. + */ +export const PAGE_LIST_MAX_IDS = 200; -export const pageListCursorSchema = z.object({ - updatedAt: z.string().max(64).regex(POSTGRES_TIMESTAMPTZ_TEXT), - id: pageFields.id, -}); +export const PAGE_LIST_SCOPES = ["all", "team", "mine"] as const; + +export type PageListScope = (typeof PAGE_LIST_SCOPES)[number]; + +/** + * An empty search is the cleared search box, not a request for pages whose + * title contains "". Normalising here rather than at each caller keeps a + * client from having to strip the key to get the unfiltered list back. + */ +const searchField = z + .string() + .max(200) + .transform((value) => value.trim()) + .transform((value) => (value.length === 0 ? undefined : value)) + .optional(); + +const pageListFilterFields = { + workspaceId: pageFields.workspaceId.optional(), + search: searchField, + scope: z.enum(PAGE_LIST_SCOPES).default("all"), + authorId: z.string().uuid().optional(), + ids: z.array(pageFields.id).max(PAGE_LIST_MAX_IDS).optional(), +} as const; export const listPagesSchema = z .object({ - workspaceId: pageFields.workspaceId.optional(), - search: z.string().min(1).max(200).optional(), - cursor: pageListCursorSchema.optional(), + ...pageListFilterFields, + cursor: z.string().max(256).optional(), limit: z .number() .int() @@ -113,7 +135,22 @@ export const listPagesSchema = z }) .optional(); -export type PageListCursor = z.infer; +/** + * The tab counts. They are a separate query because they are counts over the + * whole filtered set, which a paginated list can no longer derive from what it + * has loaded. + */ +export const pageCountsSchema = z + .object({ + workspaceId: pageListFilterFields.workspaceId, + search: pageListFilterFields.search, + authorId: pageListFilterFields.authorId, + pinnedIds: pageListFilterFields.ids, + }) + .optional(); + +export type ListPagesInput = z.infer; +export type PageCountsInput = z.infer; const pageRefFieldsSchema = z.object({ id: pageFields.id.optional(), From a01e9b3a84ec20dcd0c6545da611e5be61fff762 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Wed, 23 Sep 2026 09:05:47 +0530 Subject: [PATCH 12/16] refactor(pages): answer the comment sweep in one query, and stop over-fetching names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining findings from the #7726 review, all Medium or Low. `pages comments list` with no --page fetched every page in the organization and then called pageComment.list once per page, eight at a time — a call count that grew with the org rather than with the comments. A new pageComment.listForOrganization answers it in one query, joining pages once and carrying the visibility rule in the join rather than loading each page to check it. It also takes the workspace and unresolved filters, so the CLI stops post-filtering what it over-fetched. PagesView pulled useAccessibleV2Workspaces — 893 lines that fetch host members, org members, GitHub repositories and sidebar state — to turn ids into names. usePageWorkspaceNames reads the host fan-out that already runs in HostWorkspacesProvider plus the shared cloud workspace query, and returns the id-to-name map, which is all the filter needed. `pages list` called cloudWorkspace.list and listWorkspacesOnHost on every run, including under --quiet where only ids print, and the host call waits on a machine that may be asleep. It now resolves names only when a returned page is actually linked to a workspace, or when the inherited-workspace notice needs one. escapeLikePattern existed four times (page.ts, automation.ts, task-list-query.ts, and as escapeLike in leaderboard/queries.ts), all identical. It lives in @superset/db/utils now. The CLI's --search help said "title or slug"; the server matches description too, as the desktop matcher always did. list-for-organization.integration.ts covers the new query against Postgres: that a colleague's private page stays hidden and is visible to its owner, the workspace and unresolved and activated filters, and the oldest-first order the per-page list had. Left alone: plugins/superset/skills/page/SKILL.md still says --search matches "title or slug". Correcting it needs a plugin publish and tag, which this PR already deferred. --- .../components/PagesView/PagesView.test.tsx | 11 +- .../pages/components/PagesView/PagesView.tsx | 29 +- .../hooks/usePageWorkspaceNames/index.ts | 1 + .../usePageWorkspaceNames.ts | 29 ++ .../commands/pages/comments/list/command.ts | 51 +--- .../cli/src/commands/pages/list/command.ts | 39 ++- packages/db/src/task-list-query.ts | 5 +- packages/db/src/utils/index.ts | 1 + packages/db/src/utils/like.ts | 7 + .../trpc/src/router/automation/automation.ts | 5 +- .../trpc/src/router/leaderboard/queries.ts | 8 +- .../list-for-organization.integration.ts | 259 ++++++++++++++++++ .../src/router/page-comment/page-comment.ts | 111 +++++++- .../trpc/src/router/page-comment/schema.ts | 11 + packages/trpc/src/router/page/page.ts | 5 +- 15 files changed, 472 insertions(+), 100 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/hooks/usePageWorkspaceNames/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/hooks/usePageWorkspaceNames/usePageWorkspaceNames.ts create mode 100644 packages/db/src/utils/like.ts create mode 100644 packages/trpc/src/router/page-comment/list-for-organization.integration.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx index 826ec531e33..ae5b0ddb7f0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx @@ -148,14 +148,9 @@ mock.module( }), ); -mock.module( - "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces", - () => ({ - useAccessibleV2Workspaces: () => ({ - all: [{ id: "ws-1", name: "Workspace One" }], - }), - }), -); +mock.module("../../hooks/usePageWorkspaceNames", () => ({ + usePageWorkspaceNames: () => new Map([["ws-1", "Workspace One"]]), +})); const { act, cleanup, render } = await import("@testing-library/react"); const { PagesView } = await import("./PagesView"); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx index ee3c99d20d3..5b55a93bc1e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx @@ -16,7 +16,7 @@ import { import { usePageFavorites } from "renderer/routes/_authenticated/_dashboard/hooks/usePageFavorites"; import { usePagesList } from "renderer/routes/_authenticated/_dashboard/hooks/usePagesList"; import { pagesListInput } from "renderer/routes/_authenticated/_dashboard/utils/pagesListInput"; -import { useAccessibleV2Workspaces } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; +import { usePageWorkspaceNames } from "../../hooks/usePageWorkspaceNames"; import { type PageScope, serverScope } from "../../utils/pageScope"; import { PagesGrid } from "../PagesGrid"; import { AuthorFilter, type PageAuthorOption } from "./components/AuthorFilter"; @@ -171,20 +171,19 @@ export function PagesView({ }); }, [countsQuery.data, currentUserId, t]); - const { all: accessibleWorkspaces } = useAccessibleV2Workspaces(); - const workspaceOptions = useMemo(() => { - const names = new Map( - accessibleWorkspaces.map((workspace) => [workspace.id, workspace.name]), - ); - return (countsQuery.data?.workspaces ?? []) - .filter((row) => names.has(row.workspaceId)) - .map((row) => ({ - workspaceId: row.workspaceId, - name: names.get(row.workspaceId) ?? row.workspaceId, - count: row.count, - })) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [countsQuery.data, accessibleWorkspaces]); + const workspaceNames = usePageWorkspaceNames(); + const workspaceOptions = useMemo( + () => + (countsQuery.data?.workspaces ?? []) + .filter((row) => workspaceNames.has(row.workspaceId)) + .map((row) => ({ + workspaceId: row.workspaceId, + name: workspaceNames.get(row.workspaceId) ?? row.workspaceId, + count: row.count, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + [countsQuery.data, workspaceNames], + ); const tabs = useMemo( () => diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/hooks/usePageWorkspaceNames/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/hooks/usePageWorkspaceNames/index.ts new file mode 100644 index 00000000000..1da5ecf470b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/hooks/usePageWorkspaceNames/index.ts @@ -0,0 +1 @@ +export { usePageWorkspaceNames } from "./usePageWorkspaceNames"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/hooks/usePageWorkspaceNames/usePageWorkspaceNames.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/hooks/usePageWorkspaceNames/usePageWorkspaceNames.ts new file mode 100644 index 00000000000..55d1901c550 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/hooks/usePageWorkspaceNames/usePageWorkspaceNames.ts @@ -0,0 +1,29 @@ +import { useMemo } from "react"; +import { useCloudWorkspaces } from "renderer/hooks/useCloudWorkspaces"; +import { useHostWorkspaces } from "renderer/routes/_authenticated/providers/HostWorkspacesProvider"; + +/** + * Workspace names for the pages workspace filter, and nothing else. + * + * `page.list` returns workspace ids: names live in each host's SQLite and in + * `cloud_workspaces`, never anywhere the server could join them. Both sources + * here are already shared — the host fan-out runs once in its provider, the + * cloud list is one query — so this adds no fetching of its own. + */ +export function usePageWorkspaceNames(): Map { + const { workspaces: hostWorkspaces } = useHostWorkspaces(); + const { workspaces: cloudWorkspaces } = useCloudWorkspaces(); + + return useMemo(() => { + const names = new Map(); + for (const workspace of hostWorkspaces) { + names.set(workspace.id, workspace.name); + } + // Second, so a sandbox rename that landed in `cloud_workspaces` wins over + // a stale row a host is still serving. + for (const workspace of cloudWorkspaces ?? []) { + names.set(workspace.id, workspace.name); + } + return names; + }, [hostWorkspaces, cloudWorkspaces]); +} diff --git a/packages/cli/src/commands/pages/comments/list/command.ts b/packages/cli/src/commands/pages/comments/list/command.ts index 4fdadeafc86..7213b56368f 100644 --- a/packages/cli/src/commands/pages/comments/list/command.ts +++ b/packages/cli/src/commands/pages/comments/list/command.ts @@ -1,7 +1,6 @@ import { boolean, CLIError, string } from "@superset/cli-framework"; import { command } from "../../../../lib/command"; import { resolvePageId } from "../../pageId"; -import { fetchAllPages } from "../../pageList"; import { agentSessionId } from "../agentSession"; interface ThreadComment { @@ -47,25 +46,13 @@ export default command({ ...activatedOnly, })) as unknown as Thread[]; } else { - const pages = await fetchAllPages<{ - id: string; - title: string; - slug: string; - }>(ctx, options.workspace ? { workspaceId: options.workspace } : {}); - const perPage = await mapWithConcurrency(pages, 8, async (page) => { - const rows = (await ctx.api.pageComment.list.query({ - pageId: page.id, - ...activatedOnly, - })) as unknown as Thread[]; - return rows.map((row) => ({ - ...row, - pageTitle: page.title, - pageSlug: page.slug, - })); - }); - threads = perPage - .flat() - .sort((a, b) => stamp(a.createdAt) - stamp(b.createdAt)); + // One org-scoped query. Asking page by page was a call per page in the + // organization, and it grew with the org rather than with the comments. + threads = (await ctx.api.pageComment.listForOrganization.query({ + ...(options.workspace ? { workspaceId: options.workspace } : {}), + ...activatedOnly, + ...(options.unresolved ? { unresolvedOnly: true } : {}), + })) as unknown as Thread[]; } if (options.unresolved) { @@ -114,30 +101,6 @@ export default command({ }, }); -function stamp(value: string | Date): number { - return value instanceof Date ? value.getTime() : Date.parse(value); -} - -async function mapWithConcurrency( - items: readonly T[], - limit: number, - work: (item: T) => Promise, -): Promise { - const results = new Array(items.length); - let next = 0; - const workers = Array.from( - { length: Math.min(limit, items.length) }, - async () => { - while (next < items.length) { - const index = next++; - results[index] = await work(items[index] as T); - } - }, - ); - await Promise.all(workers); - return results; -} - function indent(text: string, prefix: string): string { return text .split("\n") diff --git a/packages/cli/src/commands/pages/list/command.ts b/packages/cli/src/commands/pages/list/command.ts index 735cb695ff0..a49aae5d55e 100644 --- a/packages/cli/src/commands/pages/list/command.ts +++ b/packages/cli/src/commands/pages/list/command.ts @@ -87,7 +87,7 @@ export default command({ ), search: string() .alias("q") - .desc("Only pages whose title or slug contains this text"), + .desc("Only pages whose title, slug or description contains this text"), limit: number() .int() .min(1) @@ -111,28 +111,37 @@ export default command({ }) : undefined; - const names = await workspaceNames(ctx); - - if (workspaceId && !asked) { - const label = names.get(workspaceId) ?? workspaceId; - process.stderr.write( - `Showing pages from the current workspace (${label}). Pass --workspace '' for the whole organization.\n`, - ); - } - const query = { ...(workspaceId ? { workspaceId } : {}), ...(options.search ? { search: options.search } : {}), ...(options.limit !== undefined ? { limit: options.limit } : {}), }; - if (options.limit === undefined && options.cursor === undefined) { - return { - data: nameLinks(await fetchAllPages(ctx, query), names), - }; + const drains = options.limit === undefined && options.cursor === undefined; + const result = drains + ? { items: await fetchAllPages(ctx, query), nextCursor: null } + : await fetchPageList(ctx, query, options.cursor); + + // Naming a workspace costs a cloud call and a host call, and the host one + // waits on a machine that may be asleep. Under `--quiet` only ids are + // printed, and with no linked page there is nothing to name. + const quiet = (options as Record).quiet === true; + const wanted = + !quiet && + (Boolean(workspaceId && !asked) || + result.items.some((row) => row.workspaceLinks?.length)); + const names = wanted + ? await workspaceNames(ctx) + : new Map(); + + if (workspaceId && !asked) { + const label = names.get(workspaceId) ?? workspaceId; + process.stderr.write( + `Showing pages from the current workspace (${label}). Pass --workspace '' for the whole organization.\n`, + ); } - const result = await fetchPageList(ctx, query, options.cursor); + if (drains) return { data: nameLinks(result.items, names) }; return { data: { items: nameLinks(result.items, names), diff --git a/packages/db/src/task-list-query.ts b/packages/db/src/task-list-query.ts index ad705bc0d71..519a0947475 100644 --- a/packages/db/src/task-list-query.ts +++ b/packages/db/src/task-list-query.ts @@ -15,6 +15,7 @@ import { import { QueryBuilder } from "drizzle-orm/pg-core"; import { taskStatuses, tasks } from "./schema"; import { type TaskPriority, taskPriorityValues } from "./schema/enums"; +import { escapeLikePattern } from "./utils/like"; export const taskStatusTypeValues = [ "backlog", @@ -53,10 +54,6 @@ export interface TaskListFilters { dueDateTo?: Date; } -function escapeLikePattern(value: string): string { - return value.replace(/[\\%_]/g, (match) => `\\${match}`); -} - export function buildTaskListConditions( filters: TaskListFilters, ): SQL[] { diff --git a/packages/db/src/utils/index.ts b/packages/db/src/utils/index.ts index 60c8874cf7e..4d012e55063 100644 --- a/packages/db/src/utils/index.ts +++ b/packages/db/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from "./like"; export * from "./membership"; export * from "./provider-identity"; export * from "./sql"; diff --git a/packages/db/src/utils/like.ts b/packages/db/src/utils/like.ts new file mode 100644 index 00000000000..7258e3051a1 --- /dev/null +++ b/packages/db/src/utils/like.ts @@ -0,0 +1,7 @@ +/** + * Escapes the wildcards in a user-supplied LIKE/ILIKE term, so searching for + * "100%" matches that text instead of every row. + */ +export function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (match) => `\\${match}`); +} diff --git a/packages/trpc/src/router/automation/automation.ts b/packages/trpc/src/router/automation/automation.ts index da60f91f2b0..ee104165e2a 100644 --- a/packages/trpc/src/router/automation/automation.ts +++ b/packages/trpc/src/router/automation/automation.ts @@ -7,6 +7,7 @@ import { v2UsersHosts, v2Workspaces, } from "@superset/db/schema"; +import { escapeLikePattern } from "@superset/db/utils"; import type { DraftTrigger } from "@superset/shared/automation-triggers"; import { AUTOMATIONS_REQUIRED_PLAN, @@ -74,10 +75,6 @@ async function requireAutomationsPlan( return organizationId; } -function escapeLikePattern(value: string): string { - return value.replace(/[\\%_]/g, (match) => `\\${match}`); -} - async function verifyHostAccess( userId: string, organizationId: string, diff --git a/packages/trpc/src/router/leaderboard/queries.ts b/packages/trpc/src/router/leaderboard/queries.ts index 4776148cde1..ce822fb3dd7 100644 --- a/packages/trpc/src/router/leaderboard/queries.ts +++ b/packages/trpc/src/router/leaderboard/queries.ts @@ -5,6 +5,7 @@ import { publicProfiles, users, } from "@superset/db/schema"; +import { escapeLikePattern } from "@superset/db/utils"; import { and, desc, eq, gt, gte, isNull, lte, sql } from "drizzle-orm"; import { type LeaderboardPeriod, resolveWindow } from "./periods"; import { type Tier, tierProgress } from "./tier"; @@ -377,9 +378,6 @@ export async function listPublicHandles(): Promise< export const SEARCH_MIN_LENGTH = 2; -const escapeLike = (value: string) => - value.replace(/[\\%_]/g, (char) => `\\${char}`); - interface SearchRow extends Record { handle: string; name: string | null; @@ -406,8 +404,8 @@ export async function searchParticipants( const range = resolveWindow(opts); const byCost = opts.metric === "cost"; - const prefix = `${escapeLike(query)}%`; - const contains = `%${escapeLike(query)}%`; + const prefix = `${escapeLikePattern(query)}%`; + const contains = `%${escapeLikePattern(query)}%`; const take = Math.min(limit, SEARCH_LIMIT); const ranked = range diff --git a/packages/trpc/src/router/page-comment/list-for-organization.integration.ts b/packages/trpc/src/router/page-comment/list-for-organization.integration.ts new file mode 100644 index 00000000000..9c15894c7fa --- /dev/null +++ b/packages/trpc/src/router/page-comment/list-for-organization.integration.ts @@ -0,0 +1,259 @@ +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; +import { createHash } from "node:crypto"; + +mock.module("../../lib/r2", () => ({ + presignedGetUrl: async (key: string) => `https://storage.test/${key}`, + presignedPutUrl: async ({ key }: { key: string }) => ({ + url: `https://storage.test/${key}`, + headers: {}, + }), + putObject: async () => {}, + getObject: async () => null, + headObject: async () => null, + copyObject: async () => {}, + deleteObjects: async () => {}, + objectExists: async () => true, +})); + +const { db, dbWs } = await import("@superset/db/client"); +const { + members, + organizations, + pageComments, + pageCommentThreads, + pages, + pageVersions, + users, + workspacePages, +} = await import("@superset/db/schema"); +const { eq } = await import("drizzle-orm"); +const { pageCommentRouter } = await import("./page-comment"); +const { createCallerFactory, createTRPCContext, createTRPCRouter } = + await import("../../trpc"); + +const ORG = crypto.randomUUID(); +const USER = crypto.randomUUID(); +const OTHER_USER = crypto.randomUUID(); +const WORKSPACE = crypto.randomUUID(); +const suffix = Date.now(); + +const callerFor = (userId: string) => + createCallerFactory(createTRPCRouter({ pageComment: pageCommentRouter }))( + createTRPCContext({ + session: { + user: { id: userId, email: `u-${userId}@example.com` }, + session: { activeOrganizationId: ORG }, + } as never, + auth: {} as never, + headers: new Headers(), + }), + ); + +const caller = callerFor(USER); + +/** id of each page, keyed by the label the test refers to it by. */ +const pageIds: Record = {}; +const threadIds: Record = {}; + +async function seedPage( + label: string, + { + visibility, + owner, + inWorkspace = false, + }: { + visibility: "org" | "just_me"; + owner: string; + inWorkspace?: boolean; + }, +) { + const [page] = await db + .insert(pages) + .values({ + slug: `comment-sweep-${suffix}-${label}`, + organizationId: ORG, + createdByUserId: owner, + title: `Page ${label}`, + visibility, + }) + .returning({ id: pages.id }); + if (!page) throw new Error("failed to insert page"); + pageIds[label] = page.id; + + const [version] = await db + .insert(pageVersions) + .values({ + pageId: page.id, + version: 1, + storageKey: `pages/${page.id}/1/index.html`, + contentType: "text/html", + sizeBytes: 10, + sha256: createHash("sha256").update(label).digest("hex"), + createdByUserId: owner, + }) + .returning({ id: pageVersions.id }); + if (!version) throw new Error("failed to insert version"); + + if (inWorkspace) { + await db.insert(workspacePages).values({ + pageId: page.id, + workspaceId: WORKSPACE, + entryPath: `/${label}.html`, + }); + } + + const [thread] = await db + .insert(pageCommentThreads) + .values({ + pageId: page.id, + pageVersionId: version.id, + anchorKind: "page", + anchor: null, + createdByUserId: owner, + ...(label === "resolved" ? { resolvedAt: new Date() } : {}), + ...(label === "activated" ? { agentActivatedAt: new Date() } : {}), + }) + .returning({ id: pageCommentThreads.id }); + if (!thread) throw new Error("failed to insert thread"); + threadIds[label] = thread.id; + + await db.insert(pageComments).values({ + threadId: thread.id, + body: `comment on ${label}`, + authorKind: "human", + authorUserId: owner, + }); +} + +beforeAll(async () => { + await db.insert(organizations).values({ + id: ORG, + name: "Sweep Org", + slug: `sweep-org-${suffix}`, + }); + await db.insert(users).values([ + { + id: USER, + name: "Sweep User", + email: `sweep-${suffix}@example.com`, + organizationIds: [ORG], + }, + { + id: OTHER_USER, + name: "Other User", + email: `sweep-other-${suffix}@example.com`, + organizationIds: [ORG], + }, + ]); + await db.insert(members).values([ + { + id: crypto.randomUUID(), + organizationId: ORG, + userId: USER, + role: "owner", + createdAt: new Date(), + }, + { + id: crypto.randomUUID(), + organizationId: ORG, + userId: OTHER_USER, + role: "member", + createdAt: new Date(), + }, + ]); + + await seedPage("shared", { visibility: "org", owner: OTHER_USER }); + await seedPage("mine", { visibility: "just_me", owner: USER }); + await seedPage("theirs", { visibility: "just_me", owner: OTHER_USER }); + await seedPage("linked", { + visibility: "org", + owner: USER, + inWorkspace: true, + }); + await seedPage("resolved", { visibility: "org", owner: USER }); + await seedPage("activated", { visibility: "org", owner: USER }); +}); + +afterAll(async () => { + await db.delete(organizations).where(eq(organizations.id, ORG)); + await db.delete(users).where(eq(users.id, USER)); + await db.delete(users).where(eq(users.id, OTHER_USER)); + await dbWs.$client.end?.(); +}); + +describe("pageComment.listForOrganization", () => { + test("sweeps every readable page in one call", async () => { + const threads = await caller.pageComment.listForOrganization({}); + const ids = threads.map((thread) => thread.id); + + expect(ids).toContain(threadIds.shared); + expect(ids).toContain(threadIds.mine); + expect(ids).toContain(threadIds.linked); + }); + + test("applies the same visibility rule page.list does", async () => { + const threads = await caller.pageComment.listForOrganization({}); + const ids = threads.map((thread) => thread.id); + + // A colleague's `just_me` page is unreadable, so its thread is not listed. + expect(ids).not.toContain(threadIds.theirs); + // ...and it is listed for the person who owns it. + const theirs = await callerFor(OTHER_USER).pageComment.listForOrganization( + {}, + ); + expect(theirs.map((thread) => thread.id)).toContain(threadIds.theirs); + }); + + test("names the page each thread belongs to, so a sweep can be grouped", async () => { + const threads = await caller.pageComment.listForOrganization({}); + const shared = threads.find((thread) => thread.id === threadIds.shared); + + expect(shared?.pageTitle).toBe("Page shared"); + expect(shared?.pageSlug).toBe(`comment-sweep-${suffix}-shared`); + expect(shared?.pageId).toBe(pageIds.shared); + }); + + test("carries the comments, not just the threads", async () => { + const threads = await caller.pageComment.listForOrganization({}); + const shared = threads.find((thread) => thread.id === threadIds.shared); + + expect(shared?.comments.map((comment) => comment.body)).toEqual([ + "comment on shared", + ]); + }); + + test("narrows to one workspace's pages", async () => { + const threads = await caller.pageComment.listForOrganization({ + workspaceId: WORKSPACE, + }); + + expect(threads.map((thread) => thread.id)).toEqual([threadIds.linked]); + }); + + test("narrows to unresolved threads", async () => { + const threads = await caller.pageComment.listForOrganization({ + unresolvedOnly: true, + }); + const ids = threads.map((thread) => thread.id); + + expect(ids).not.toContain(threadIds.resolved); + expect(ids).toContain(threadIds.shared); + }); + + test("narrows to the threads an agent was activated on", async () => { + const threads = await caller.pageComment.listForOrganization({ + activatedOnly: true, + }); + + expect(threads.map((thread) => thread.id)).toEqual([threadIds.activated]); + }); + + test("orders oldest first, the way the per-page list did", async () => { + const threads = await caller.pageComment.listForOrganization({}); + const stamps = threads.map((thread) => + new Date(thread.createdAt).getTime(), + ); + + expect([...stamps].sort((a, b) => a - b)).toEqual(stamps); + }); +}); diff --git a/packages/trpc/src/router/page-comment/page-comment.ts b/packages/trpc/src/router/page-comment/page-comment.ts index a385188496a..90c01e2b687 100644 --- a/packages/trpc/src/router/page-comment/page-comment.ts +++ b/packages/trpc/src/router/page-comment/page-comment.ts @@ -6,9 +6,10 @@ import { pageVersions, type SelectPage, users, + workspacePages, } from "@superset/db/schema"; import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; -import { and, asc, eq, isNotNull, isNull } from "drizzle-orm"; +import { and, asc, eq, inArray, isNotNull, isNull, or } from "drizzle-orm"; import { protectedProcedure, userError } from "../../trpc"; import { assertPageReadable } from "../page/access"; import { requireActiveOrgMembership } from "../utils/active-org"; @@ -21,6 +22,7 @@ import { createPageCommentThreadSchema, deletePageCommentThreadSchema, editPageCommentSchema, + listOrganizationPageCommentsSchema, listPageCommentsSchema, replyPageCommentSchema, resolvePageCommentThreadSchema, @@ -157,6 +159,113 @@ export const pageCommentRouter = { ); }), + /** + * One query for the whole organization, so a sweep does not call `list` + * once per page. Visibility is the same rule `page.list` applies, enforced + * in the join rather than by loading each page. + */ + listForOrganization: protectedProcedure + .input(listOrganizationPageCommentsSchema) + .query(async ({ ctx, input }) => { + const organizationId = await requireActiveOrgMembership(ctx); + const userId = ctx.session.user.id; + + const activatedOnly = ctx.agentCaller + ? true + : (input?.activatedOnly ?? false); + + const readable = and( + eq(pages.organizationId, organizationId), + or( + eq(pages.visibility, "org"), + eq(pages.visibility, "everyone"), + and( + eq(pages.visibility, "just_me"), + eq(pages.createdByUserId, userId), + ), + ), + activatedOnly + ? isNotNull(pageCommentThreads.agentActivatedAt) + : undefined, + input?.unresolvedOnly + ? isNull(pageCommentThreads.resolvedAt) + : undefined, + ); + + let scoped = db + .select({ + thread: pageCommentThreads, + version: pageVersions.version, + pageTitle: pages.title, + pageSlug: pages.slug, + }) + .from(pageCommentThreads) + .innerJoin(pages, eq(pages.id, pageCommentThreads.pageId)) + .innerJoin( + pageVersions, + eq(pageVersions.id, pageCommentThreads.pageVersionId), + ) + .$dynamic(); + + if (input?.workspaceId) { + scoped = scoped.innerJoin( + workspacePages, + and( + eq(workspacePages.pageId, pages.id), + eq(workspacePages.workspaceId, input.workspaceId), + ), + ); + } + + const threadRows = await scoped + .where(readable) + .orderBy(asc(pageCommentThreads.createdAt)); + + if (threadRows.length === 0) return []; + + const commentRows = await db + .select({ + comment: pageComments, + authorName: users.name, + authorImage: users.image, + }) + .from(pageComments) + .leftJoin(users, eq(users.id, pageComments.authorUserId)) + .where( + and( + inArray( + pageComments.threadId, + threadRows.map((row) => row.thread.id), + ), + isNull(pageComments.deletedAt), + ), + ) + .orderBy(asc(pageComments.createdAt)); + + const byThread = new Map(); + for (const row of commentRows) { + const existing = byThread.get(row.comment.threadId); + if (existing) existing.push(row); + else byThread.set(row.comment.threadId, [row]); + } + + return threadRows.map(({ thread, version, pageTitle, pageSlug }) => ({ + ...shapeThread( + thread, + version, + (byThread.get(thread.id) ?? []).map((row) => + shapeComment(row.comment, { + name: row.authorName, + image: row.authorImage, + }), + ), + ), + pageId: thread.pageId, + pageTitle, + pageSlug, + })); + }), + create: protectedProcedure .input(createPageCommentThreadSchema) .mutation(async ({ ctx, input }) => { diff --git a/packages/trpc/src/router/page-comment/schema.ts b/packages/trpc/src/router/page-comment/schema.ts index ee96b80507c..43cfc426fb4 100644 --- a/packages/trpc/src/router/page-comment/schema.ts +++ b/packages/trpc/src/router/page-comment/schema.ts @@ -24,6 +24,17 @@ export const listPageCommentsSchema = z.object({ activatedOnly: z.boolean().optional(), }); +/** + * Every thread in the organization, for a caller that would otherwise ask + * page by page. `workspaceId` narrows to the pages published from one + * workspace, matching `page.list`. + */ +export const listOrganizationPageCommentsSchema = z.object({ + workspaceId: z.string().uuid().optional(), + activatedOnly: z.boolean().optional(), + unresolvedOnly: z.boolean().optional(), +}); + export const createPageCommentThreadSchema = z .object({ pageId: z.string().uuid(), diff --git a/packages/trpc/src/router/page/page.ts b/packages/trpc/src/router/page/page.ts index 1a0fbff009c..68932cad50c 100644 --- a/packages/trpc/src/router/page/page.ts +++ b/packages/trpc/src/router/page/page.ts @@ -10,6 +10,7 @@ import { users, workspacePages, } from "@superset/db/schema"; +import { escapeLikePattern } from "@superset/db/utils"; import { mintPageSlug } from "@superset/shared/page-slug"; import { fileOriginalKey, @@ -71,10 +72,6 @@ import { enqueuePageThumbnail } from "./thumbnail"; import { watchState } from "./watch"; import { assertWorkspaceAccess } from "./workspace-access"; -function escapeLikePattern(term: string): string { - return term.replace(/[\\%_]/g, (char) => `\\${char}`); -} - function visibilityFilter(userId: string) { return or( eq(pages.visibility, "org"), From 8c0eb67c95e95388a5cbf5a2dc643561d03ff663 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Wed, 23 Sep 2026 09:22:44 +0530 Subject: [PATCH 13/16] fix(pages): keep the menu's invalidation matching its query, and stop cancelling a pending search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the review of the previous two commits. Both are regressions those commits introduced. `invalidate(input)` matches a cached query by partial deep equality on its input, so the menu's `limit` has to be the same on both sides. Giving it MENU_PAGE_LIMIT for the query while the two invalidation sites still built `pagesListInput({ workspaceId })` left them at 200 against 50, which matched nothing: neither a publish from the workspace nor opening the menu refreshed the list, and it went stale until the 60s staleTime lapsed. Query and invalidation now derive from one filter, so the literal exists once and cannot drift again. PagesView cancelled a pending debounced search whenever another filter changed. That was copied from PullRequestsView, which needs it because it builds its search object imperatively — but every handler on this route updates the URL through a functional updater, so a search landing after a scope change composes with it rather than clobbering it. Cancelling only threw the keystrokes away: the box kept showing the typed text while the grid and the counts stayed unfiltered by it, until the next keystroke. The cancellation is gone, along with the three wrappers that existed for it. The regression test drives the real Radix trigger (pointerDown, not click — click alone never reaches it, which made a first attempt at this test pass against the bug) and was checked by reintroducing the cancellation. --- .../components/PagesView/PagesView.test.tsx | 36 +++++++++++++++++-- .../pages/components/PagesView/PagesView.tsx | 28 ++++++--------- .../WorkspacePagesMenu/WorkspacePagesMenu.tsx | 23 ++++++++---- 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx index ae5b0ddb7f0..1258a82e82d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx @@ -120,6 +120,7 @@ mock.module("../PagesGrid", () => ({ })); const onScopeChange = mock((_scope: string) => {}); +const onSearchChange = mock((_search: string) => {}); mock.module("./hooks/useCreatePageWithAgent", () => ({ useCreatePageWithAgent: () => ({ @@ -152,7 +153,9 @@ mock.module("../../hooks/usePageWorkspaceNames", () => ({ usePageWorkspaceNames: () => new Map([["ws-1", "Workspace One"]]), })); -const { act, cleanup, render } = await import("@testing-library/react"); +const { act, cleanup, fireEvent, render } = await import( + "@testing-library/react" +); const { PagesView } = await import("./PagesView"); function renderView({ @@ -167,7 +170,7 @@ function renderView({ scope={scope} authorId={authorId} workspaceId={workspaceId} - onSearchChange={mock()} + onSearchChange={onSearchChange} onScopeChange={onScopeChange} onAuthorChange={mock()} onWorkspaceChange={mock()} @@ -178,6 +181,7 @@ function renderView({ beforeEach(() => { fetchNextPage.mockClear(); onScopeChange.mockClear(); + onSearchChange.mockClear(); observed.length = 0; listInput = undefined; countsInput = undefined; @@ -316,6 +320,34 @@ describe("PagesView counts", () => { }); }); +describe("PagesView search", () => { + test("does not strand typed text in the box when another filter changes", async () => { + const view = await act(async () => renderView()); + const box = view.getByPlaceholderText("Search pages") as HTMLInputElement; + + await act(async () => { + fireEvent.change(box, { target: { value: "quarterly" } }); + }); + expect(box.value).toBe("quarterly"); + + // Switching tabs inside the debounce window must not drop the pending + // search: the box would keep showing text the grid was not filtered by. + await act(async () => { + const tab = view.getByRole("tab", { name: /Team/ }); + fireEvent.pointerDown(tab, { button: 0, ctrlKey: false }); + fireEvent.mouseDown(tab, { button: 0, ctrlKey: false }); + fireEvent.click(tab); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + }); + + expect(onScopeChange).toHaveBeenCalledWith("team"); + expect(onSearchChange).toHaveBeenCalledWith("quarterly"); + }); +}); + describe("PagesView empty state", () => { test("does not blank a loaded grid while the counts are still in flight", async () => { countsData = undefined as never; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx index 5b55a93bc1e..c4280d28110 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx @@ -63,9 +63,15 @@ export function PagesView({ const openPage = useOpenPage(); // The input answers the keystroke; the URL — and so the query — settles. + // + // A pending search is deliberately never cancelled when another filter + // changes: every handler here updates the URL through a functional updater, + // so a search landing after a scope change composes with it instead of + // clobbering it. Cancelling would strand the typed text in the box while the + // grid stayed unfiltered. const [searchInput, setSearchInput] = useState(search); useEffect(() => setSearchInput(search), [search]); - const { scheduleSearchNavigation, cancelPendingSearchNavigation } = + const { scheduleSearchNavigation } = useDebouncedSearchNavigation(onSearchChange); const handleSearchChange = useCallback( (value: string) => { @@ -194,14 +200,6 @@ export function PagesView({ [counts.pinned, scope], ); - const changeScope = useCallback( - (next: PageScope) => { - cancelPendingSearchNavigation(); - onScopeChange(next); - }, - [cancelPendingSearchNavigation, onScopeChange], - ); - const hasFilters = Boolean(search.trim()) || scope !== "all" || @@ -230,7 +228,7 @@ export function PagesView({
changeScope(value as PageScope)} + onValueChange={(value) => onScopeChange(value as PageScope)} > {tabs.map((tab) => ( @@ -253,20 +251,14 @@ export function PagesView({ { - cancelPendingSearchNavigation(); - onWorkspaceChange(value); - }} + onChange={onWorkspaceChange} /> )} {(authorOptions.length > 1 || authorId !== null) && ( { - cancelPendingSearchNavigation(); - onAuthorChange(value); - }} + onChange={onAuthorChange} /> )}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx index 19f750d21e2..9323f63c816 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspacePagesMenu/WorkspacePagesMenu.tsx @@ -48,11 +48,20 @@ export function WorkspacePagesMenu({ const [composing, setComposing] = useState(false); // This menu orders by publish time, not creation time, so it takes one - // large batch rather than the grid's scroll-sized one. - const workspacePagesQuery = usePagesList( - { workspaceId, limit: MENU_PAGE_LIMIT }, - { staleTime: 60_000 }, + // large batch rather than the grid's scroll-sized one. Built once because + // `invalidate` matches a cached query by its input — a different `limit` + // here than below and neither a publish nor opening the menu would refresh. + const workspaceFilter = useMemo( + () => ({ workspaceId, limit: MENU_PAGE_LIMIT }), + [workspaceId], ); + const workspaceListInput = useMemo( + () => pagesListInput(workspaceFilter), + [workspaceFilter], + ); + const workspacePagesQuery = usePagesList(workspaceFilter, { + staleTime: 60_000, + }); // Only the pins, by id — this menu never needed the rest of the org. const pinnedPagesQuery = usePagesList( { ids: favoritePageIds, limit: MENU_PAGE_LIMIT }, @@ -64,8 +73,8 @@ export function WorkspacePagesMenu({ "page-watch:changed", workspaceId, useCallback(() => { - void utils.page.list.invalidate(pagesListInput({ workspaceId })); - }, [utils, workspaceId]), + void utils.page.list.invalidate(workspaceListInput); + }, [utils, workspaceListInput]), ); const { workspace, pinned, hasNew } = useMemo( @@ -87,7 +96,7 @@ export function WorkspacePagesMenu({ const handleOpenChange = (next: boolean) => { setOpen(next); if (next) { - void utils.page.list.invalidate(pagesListInput({ workspaceId })); + void utils.page.list.invalidate(workspaceListInput); return; } setComposing(false); From 3a5cfe042cfe0a94929e7ba5033f2d7e553769c6 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi Date: Wed, 23 Sep 2026 09:44:53 +0530 Subject: [PATCH 14/16] fix(pages): do not narrow a picker by the dimension it offers, and drop the thread id list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings on the previous commits, both regressions they introduced. `page.counts` built the author breakdown from the same `filters` as the scope counts, which include `authorId`. Selecting an author therefore collapsed the author list to that one author, so the picker offered only the current selection and you could not switch to another without clearing to "All authors" first. The same breakdown never joined `workspace_pages`, so with a workspace selected it offered authors who had published nothing in it, and picking one produced an empty grid. The rule both pickers now follow: a breakdown is never narrowed by the dimension it offers, because that is the choice being made, but it is narrowed by every other active filter, or it offers options that lead nowhere. The workspace breakdown already did this; the author one now does. `listForOrganization` selected comments with `inArray(threadId, …)` over every thread it had just read. That is one bind parameter per thread against Postgres' 65535-parameter ceiling, so a large enough organization would fail the call outright rather than return slowly. The comments now come through the same join and predicate as the threads, so no id list exists. Both fixes were checked by reintroducing each bug and confirming the new test fails: the author tests catch the `authorId` and workspace halves independently. Integration teardown now tolerates a sibling file having already closed the shared pooled client, so the two page integration suites can run together. Still unbounded, and not addressed here: `listForOrganization` returns every thread in the organization in one response. The sweep needs the complete set — a cap would silently hide comments — so bounding it means a cursor and a drain, which is a larger change than this fix. --- .../list-for-organization.integration.ts | 4 +- .../src/router/page-comment/page-comment.ts | 31 ++++++++---- .../trpc/src/router/page/list.integration.ts | 50 +++++++++++++++---- packages/trpc/src/router/page/page.ts | 36 ++++++++++--- 4 files changed, 94 insertions(+), 27 deletions(-) diff --git a/packages/trpc/src/router/page-comment/list-for-organization.integration.ts b/packages/trpc/src/router/page-comment/list-for-organization.integration.ts index 9c15894c7fa..a236a5e6441 100644 --- a/packages/trpc/src/router/page-comment/list-for-organization.integration.ts +++ b/packages/trpc/src/router/page-comment/list-for-organization.integration.ts @@ -178,7 +178,9 @@ afterAll(async () => { await db.delete(organizations).where(eq(organizations.id, ORG)); await db.delete(users).where(eq(users.id, USER)); await db.delete(users).where(eq(users.id, OTHER_USER)); - await dbWs.$client.end?.(); + // Guarded: the pooled client is shared, so a sibling integration file that + // already closed it must not fail this teardown. + await dbWs.$client.end?.().catch(() => {}); }); describe("pageComment.listForOrganization", () => { diff --git a/packages/trpc/src/router/page-comment/page-comment.ts b/packages/trpc/src/router/page-comment/page-comment.ts index 90c01e2b687..ea442e3715e 100644 --- a/packages/trpc/src/router/page-comment/page-comment.ts +++ b/packages/trpc/src/router/page-comment/page-comment.ts @@ -9,7 +9,7 @@ import { workspacePages, } from "@superset/db/schema"; import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; -import { and, asc, eq, inArray, isNotNull, isNull, or } from "drizzle-orm"; +import { and, asc, eq, isNotNull, isNull, or } from "drizzle-orm"; import { protectedProcedure, userError } from "../../trpc"; import { assertPageReadable } from "../page/access"; import { requireActiveOrgMembership } from "../utils/active-org"; @@ -223,23 +223,36 @@ export const pageCommentRouter = { if (threadRows.length === 0) return []; - const commentRows = await db + // Selected through the same join as the threads rather than by an id + // list: one bind parameter per thread would fail outright past + // Postgres' 65535-parameter ceiling, and this query is org-wide. + let commentQuery = db .select({ comment: pageComments, authorName: users.name, authorImage: users.image, }) .from(pageComments) + .innerJoin( + pageCommentThreads, + eq(pageCommentThreads.id, pageComments.threadId), + ) + .innerJoin(pages, eq(pages.id, pageCommentThreads.pageId)) .leftJoin(users, eq(users.id, pageComments.authorUserId)) - .where( + .$dynamic(); + + if (input?.workspaceId) { + commentQuery = commentQuery.innerJoin( + workspacePages, and( - inArray( - pageComments.threadId, - threadRows.map((row) => row.thread.id), - ), - isNull(pageComments.deletedAt), + eq(workspacePages.pageId, pages.id), + eq(workspacePages.workspaceId, input.workspaceId), ), - ) + ); + } + + const commentRows = await commentQuery + .where(and(readable, isNull(pageComments.deletedAt))) .orderBy(asc(pageComments.createdAt)); const byThread = new Map(); diff --git a/packages/trpc/src/router/page/list.integration.ts b/packages/trpc/src/router/page/list.integration.ts index 16b780cba21..407c018cc91 100644 --- a/packages/trpc/src/router/page/list.integration.ts +++ b/packages/trpc/src/router/page/list.integration.ts @@ -121,25 +121,28 @@ beforeAll(async () => { })), ); - const [first] = await db + // Index 1 specifically: `org` visibility and created by USER, so the + // workspace-scoped assertions below have exactly one expected author. + const [linked] = await db .select({ id: pages.id }) .from(pages) - .where(eq(pages.organizationId, ORG)) + .where(eq(pages.slug, `list-page-${suffix}-1`)) .limit(1); - if (first) { - await db.insert(workspacePages).values({ - pageId: first.id, - workspaceId: WORKSPACE, - entryPath: "/index.html", - }); - } + if (!linked) throw new Error("failed to find the page to link"); + await db.insert(workspacePages).values({ + pageId: linked.id, + workspaceId: WORKSPACE, + entryPath: "/index.html", + }); }); afterAll(async () => { await db.delete(organizations).where(eq(organizations.id, ORG)); await db.delete(users).where(eq(users.id, USER)); await db.delete(users).where(eq(users.id, OTHER_USER)); - await dbWs.$client.end?.(); + // Guarded: the pooled client is shared, so a sibling integration file that + // already closed it must not fail this teardown. + await dbWs.$client.end?.().catch(() => {}); }); describe("page.list keyset", () => { @@ -255,6 +258,33 @@ describe("page.counts", () => { expect((await caller.page.counts({ pinnedIds: [] })).pinned).toBe(0); }); + test("the author breakdown offers every author, not just the selected one", async () => { + const all = await caller.page.counts(); + const selected = await caller.page.counts({ authorId: OTHER_USER }); + + // Narrowing the breakdown by the dimension it offers would collapse the + // picker to whatever is already chosen, so you could never switch author. + expect(all.authors.length).toBeGreaterThan(1); + expect(selected.authors.map((row) => row.userId).sort()).toEqual( + all.authors.map((row) => row.userId).sort(), + ); + }); + + test("the author breakdown respects the selected workspace", async () => { + const counts = await caller.page.counts({ workspaceId: WORKSPACE }); + + // Only the linked page is in this workspace, and USER created it — an + // author with nothing here would lead to an empty list when picked. + expect(counts.authors.map((row) => row.userId)).toEqual([USER]); + }); + + test("the workspace breakdown is not narrowed by the selected workspace", async () => { + const counts = await caller.page.counts({ workspaceId: WORKSPACE }); + expect(counts.workspaces.map((row) => row.workspaceId)).toContain( + WORKSPACE, + ); + }); + test("a search narrows the counts too", async () => { const counts = await caller.page.counts({ search: "Report" }); const listed = await walk({ search: "Report" }); diff --git a/packages/trpc/src/router/page/page.ts b/packages/trpc/src/router/page/page.ts index 68932cad50c..9aed44b9023 100644 --- a/packages/trpc/src/router/page/page.ts +++ b/packages/trpc/src/router/page/page.ts @@ -499,9 +499,12 @@ export const pageRouter = { ) : query.where(and(...filters))); - // The workspace picker's own counts, so choosing a workspace does not - // depend on having downloaded every page to count them. Deliberately - // not narrowed by `workspaceId` — that is the option being chosen. + // Each picker's own counts, so choosing an option does not depend on + // having downloaded every page to count them. A breakdown is never + // narrowed by the dimension it offers — that is the choice being made, + // and narrowing by it collapses the list to whatever is already + // selected — but it is narrowed by every other active filter, or it + // offers options that lead to an empty list. const workspaces = await db .select({ workspaceId: workspacePages.workspaceId, @@ -512,9 +515,7 @@ export const pageRouter = { .where(and(...filters)) .groupBy(workspacePages.workspaceId); - // Likewise the author picker: bounded by how many people have published, - // not by how many pages there are. - const authors = await db + let authorsBase = db .select({ userId: pages.createdByUserId, name: users.name, @@ -523,7 +524,28 @@ export const pageRouter = { }) .from(pages) .leftJoin(users, eq(users.id, pages.createdByUserId)) - .where(and(...filters, isNotNull(pages.createdByUserId))) + .$dynamic(); + + if (input?.workspaceId) { + authorsBase = authorsBase.innerJoin( + workspacePages, + and( + eq(workspacePages.pageId, pages.id), + eq(workspacePages.workspaceId, input.workspaceId), + ), + ); + } + + const authors = await authorsBase + .where( + and( + eq(pages.organizationId, organizationId), + visibilityFilter(userId), + // `authorId` deliberately absent; `search` still applies. + ...pageFilters({ search: input?.search }), + isNotNull(pages.createdByUserId), + ), + ) .groupBy(pages.createdByUserId, users.name, users.image); return { From 3a6fff0da18cafbe2661f8dadcb888994b01a99d Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 22 Sep 2026 22:53:21 -0700 Subject: [PATCH 15/16] fix(db): drop the pages updated_at index the list no longer reads page.list now keys on (created_at, id), and nothing else orders or filters pages by updated_at. The index was the only indexed column the 30s watch heartbeat writes, so every beat paid for an index entry nobody used. Regenerates 0120 so it drops the old index and creates the keyset one. --- .../0120_pages_created_at_keyset_index.sql | 1 + packages/db/drizzle/meta/0120_snapshot.json | 23 +------------------ packages/db/drizzle/meta/_journal.json | 2 +- packages/db/src/schema/schema.ts | 6 ----- 4 files changed, 3 insertions(+), 29 deletions(-) diff --git a/packages/db/drizzle/0120_pages_created_at_keyset_index.sql b/packages/db/drizzle/0120_pages_created_at_keyset_index.sql index 7e7923f62a7..f59db0530b0 100644 --- a/packages/db/drizzle/0120_pages_created_at_keyset_index.sql +++ b/packages/db/drizzle/0120_pages_created_at_keyset_index.sql @@ -1 +1,2 @@ +DROP INDEX "pages_organization_id_updated_at_idx";--> statement-breakpoint CREATE INDEX "pages_organization_id_created_at_id_idx" ON "pages" USING btree ("organization_id","created_at" desc,"id" desc); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0120_snapshot.json b/packages/db/drizzle/meta/0120_snapshot.json index c3ff5d71a35..a36add2994f 100644 --- a/packages/db/drizzle/meta/0120_snapshot.json +++ b/packages/db/drizzle/meta/0120_snapshot.json @@ -1,5 +1,5 @@ { - "id": "60ef1f77-8f48-4241-b80f-53aec6d774df", + "id": "fa374306-8070-4c3f-aec6-d4431f1bd163", "prevId": "e2dbc465-8d48-4ac6-957e-cf51815f7afe", "version": "7", "dialect": "postgresql", @@ -8322,27 +8322,6 @@ "method": "btree", "with": {} }, - "pages_organization_id_updated_at_idx": { - "name": "pages_organization_id_updated_at_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "\"updated_at\" desc", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, "pages_organization_id_created_at_id_idx": { "name": "pages_organization_id_created_at_id_idx", "columns": [ diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 4444d082a1f..b450cd62036 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -845,7 +845,7 @@ { "idx": 120, "version": "7", - "when": 1790129403162, + "when": 1790142341751, "tag": "0120_pages_created_at_keyset_index", "breakpoints": true } diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index 2fcf822df61..179121115c3 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -1465,12 +1465,6 @@ export const pages = pgTable( }, (table) => [ uniqueIndex("pages_slug_unique").on(table.slug), - index("pages_organization_id_updated_at_idx").on( - table.organizationId, - desc(table.updatedAt), - ), - // The list keyset orders by (created_at, id). `updated_at` cannot serve it: - // the 30s watch heartbeat bumps it, which moves a row across an open cursor. index("pages_organization_id_created_at_id_idx").on( table.organizationId, desc(table.createdAt), From 0a6e204e8b31681b5b9efaa6e8ea3e7e37f03abc Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 22 Sep 2026 22:53:22 -0700 Subject: [PATCH 16/16] fix(pages): shared load-more spinner, 48-page batches, centered empty state - The grid footer uses LoadMoreSentinel like the other paginated lists, and LoadMoreSentinel shows only the dashed spinner. The red retry banner is gone; a failed batch stops quietly, as in the other lists. - Desktop asks for 48 pages a batch, so a batch fills whole rows at two and three columns. - The empty state centers like the shared Empty does elsewhere, and an org with no pages keeps the header button, tabs and search (tab counts hidden). --- .../LoadMoreSentinel/LoadMoreSentinel.tsx | 10 +- .../pages/components/PagesGrid/PagesGrid.tsx | 11 +- .../components/PagesView/PagesView.test.tsx | 26 +--- .../pages/components/PagesView/PagesView.tsx | 129 +++++++----------- .../utils/pagesListInput/pagesListInput.ts | 2 +- packages/i18n/locales/cs/messages.po | 6 - packages/i18n/locales/de/messages.po | 6 - packages/i18n/locales/en/messages.po | 6 - packages/i18n/locales/es/messages.po | 6 - packages/i18n/locales/fr/messages.po | 6 - packages/i18n/locales/id/messages.po | 6 - packages/i18n/locales/it/messages.po | 6 - packages/i18n/locales/ja/messages.po | 6 - packages/i18n/locales/ko/messages.po | 6 - packages/i18n/locales/nl/messages.po | 6 - packages/i18n/locales/pl/messages.po | 6 - packages/i18n/locales/pt-BR/messages.po | 6 - packages/i18n/locales/ru/messages.po | 6 - packages/i18n/locales/tr/messages.po | 6 - packages/i18n/locales/vi/messages.po | 6 - packages/i18n/locales/zh-CN/messages.po | 6 - packages/i18n/locales/zh-TW/messages.po | 6 - 22 files changed, 67 insertions(+), 213 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/LoadMoreSentinel/LoadMoreSentinel.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/LoadMoreSentinel/LoadMoreSentinel.tsx index cfda207cea3..a11daad01f1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/LoadMoreSentinel/LoadMoreSentinel.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/LoadMoreSentinel/LoadMoreSentinel.tsx @@ -1,6 +1,5 @@ -import { Trans } from "@lingui/react/macro"; import type { RefObject } from "react"; -import { LuRefreshCw } from "react-icons/lu"; +import { LuLoader } from "react-icons/lu"; interface LoadMoreSentinelProps { sentinelRef: RefObject; @@ -24,12 +23,7 @@ export function LoadMoreSentinel({ className="flex items-center justify-center gap-2 py-3 text-xs text-muted-foreground" > {isFetchingNextPage && ( - <> - - - Loading more… - - + )}
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx index d937943b152..2bc0c6a62e1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx @@ -76,8 +76,8 @@ export function PagesGrid({ if (pages.length === 0) { return ( - - + + {hasFilters ? ( @@ -103,7 +103,12 @@ export function PagesGrid({ {!hasFilters && ( - diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx index 1258a82e82d..c39578402cf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.test.tsx @@ -242,7 +242,7 @@ describe("PagesView server-side filtering", () => { await act(async () => { renderView(); }); - expect(listInput?.limit).toBe(50); + expect(listInput?.limit).toBe(48); }); }); @@ -353,15 +353,14 @@ describe("PagesView empty state", () => { countsData = undefined as never; const view = await act(async () => renderView()); expect(gridProps.pages).toHaveLength(1); - // The filter bar is hidden only when the organization really has no pages. expect(view.queryByRole("tab", { name: /All/ })).toBeTruthy(); }); - test("hides the filters when the organization has no pages at all", async () => { + test("keeps the tabs but drops their counts when the organization has no pages", async () => { listResult.data = { pages: [{ items: [], nextCursor: null }] }; countsData.all = 0; const view = await act(async () => renderView()); - expect(view.queryByRole("tab", { name: /All/ })).toBeNull(); + expect(view.getByRole("tab", { name: /All/ }).textContent).toBe("All"); }); }); @@ -377,25 +376,6 @@ describe("PagesView error surfacing", () => { expect(gridProps.pages).toHaveLength(1); }); - test("retries the failed batch on demand", async () => { - listResult.hasNextPage = true; - listResult.isFetchNextPageError = true; - listResult.error = { message: "network down" }; - const view = await act(async () => renderView()); - const retry = view.getByRole("button", { name: "Retry" }); - fetchNextPage.mockClear(); - await act(async () => { - retry.click(); - }); - expect(fetchNextPage).toHaveBeenCalled(); - }); - - test("stays quiet while paging is going fine", async () => { - listResult.hasNextPage = true; - const view = await act(async () => renderView()); - expect(view.queryByRole("button", { name: "Retry" })).toBeNull(); - }); - test("shows the error when the first batch failed and nothing loaded", async () => { listResult.data = { pages: [] }; listResult.error = { message: "network down" }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx index c4280d28110..9518f30ee3a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesView/PagesView.tsx @@ -8,6 +8,7 @@ import { LuSearch } from "react-icons/lu"; import { authClient } from "renderer/lib/auth-client"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; import { FeatureHeader } from "renderer/routes/_authenticated/_dashboard/components/FeatureHeader"; +import { LoadMoreSentinel } from "renderer/routes/_authenticated/_dashboard/components/LoadMoreSentinel"; import { useDebouncedSearchNavigation } from "renderer/routes/_authenticated/_dashboard/hooks/useDebouncedSearchNavigation"; import { isPaneModifier, @@ -93,14 +94,8 @@ export function PagesView({ ); const pages = usePagesList(filter); - const { - items, - isFetchingNextPage, - isFetchNextPageError, - fetchNextPage, - scrollRef, - sentinelRef, - } = pages; + const { items, hasNextPage, isFetchingNextPage, scrollRef, sentinelRef } = + pages; const countsQuery = cloudTrpc.page.counts.useQuery({ ...(search ? { search } : {}), @@ -221,60 +216,59 @@ export function PagesView({ docsUrl={`${COMPANY.DOCS_URL}/pages`} onCreate={handleCreateWithAgent} isCreating={creatingWithAgent} - showCreate={!orgEmpty} /> - {!orgEmpty && ( -
- onScopeChange(value as PageScope)} - > - - {tabs.map((tab) => ( - - {tabLabels[tab.value]} +
+ onScopeChange(value as PageScope)} + > + + {tabs.map((tab) => ( + + {tabLabels[tab.value]} + {!orgEmpty && ( {counts[tab.value]} - - ))} - - + )} + + ))} + + -
- {(workspaceOptions.length > 0 || workspaceId !== null) && ( - - )} - {(authorOptions.length > 1 || authorId !== null) && ( - - )} -
- - handleSearchChange(event.target.value)} - placeholder={t({ - message: "Search pages", - })} - className="h-8 pl-7 text-sm" - /> -
+
+ {(workspaceOptions.length > 0 || workspaceId !== null) && ( + + )} + {(authorOptions.length > 1 || authorId !== null) && ( + + )} +
+ + handleSearchChange(event.target.value)} + placeholder={t({ + message: "Search pages", + })} + className="h-8 pl-7 text-sm" + />
- )} +
-
- - {isFetchingNextPage && ( -

- Loading more pages… -

- )} - - {isFetchNextPageError && ( -
- - Could not load more pages. - - -
- )} +
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts index 9cf0d8aeb76..bcf3597b40e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/pagesListInput/pagesListInput.ts @@ -1,6 +1,6 @@ import type { PageListScope } from "@superset/trpc/page-schema"; -export const PAGES_PER_BATCH = 50; +export const PAGES_PER_BATCH = 48; export interface PagesListFilter { workspaceId?: string; diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index 0f92e6534ed..bad3b52d975 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -4266,9 +4266,6 @@ msgstr "Nepodařilo se načíst prostředí." msgid "Could not load image" msgstr "Obrázek se nepodařilo načíst" -msgid "Could not load more pages." -msgstr "Další stránky se nepodařilo načíst." - msgid "Could not load plugins. Check your connection and try again." msgstr "Pluginy se nepodařilo načíst. Zkontrolujte připojení a zkuste to znovu." @@ -8459,9 +8456,6 @@ msgstr "Načítám issues…" msgid "Loading members..." msgstr "Načítám členy..." -msgid "Loading more pages…" -msgstr "Načítání dalších stránek…" - msgid "Loading more..." msgstr "Načítám další..." diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index f0489325368..2318e6020eb 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -4266,9 +4266,6 @@ msgstr "Umgebungen konnten nicht geladen werden." msgid "Could not load image" msgstr "Bild konnte nicht geladen werden" -msgid "Could not load more pages." -msgstr "Weitere Seiten konnten nicht geladen werden." - msgid "Could not load plugins. Check your connection and try again." msgstr "Plugins konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut." @@ -8459,9 +8456,6 @@ msgstr "Issues werden geladen…" msgid "Loading members..." msgstr "Mitglieder werden geladen..." -msgid "Loading more pages…" -msgstr "Weitere Seiten werden geladen…" - msgid "Loading more..." msgstr "Mehr wird geladen..." diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index 3fe83953833..e49882d939a 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -4266,9 +4266,6 @@ msgstr "Could not load environments." msgid "Could not load image" msgstr "Could not load image" -msgid "Could not load more pages." -msgstr "Could not load more pages." - msgid "Could not load plugins. Check your connection and try again." msgstr "Could not load plugins. Check your connection and try again." @@ -8459,9 +8456,6 @@ msgstr "Loading issues…" msgid "Loading members..." msgstr "Loading members..." -msgid "Loading more pages…" -msgstr "Loading more pages…" - msgid "Loading more..." msgstr "Loading more..." diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index 144c88b4773..da33b601014 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -4266,9 +4266,6 @@ msgstr "No se pudieron cargar los entornos." msgid "Could not load image" msgstr "No se pudo cargar la imagen" -msgid "Could not load more pages." -msgstr "No se pudieron cargar más páginas." - msgid "Could not load plugins. Check your connection and try again." msgstr "No se pudieron cargar los plugins. Comprueba tu conexión e inténtalo de nuevo." @@ -8459,9 +8456,6 @@ msgstr "Cargando issues…" msgid "Loading members..." msgstr "Cargando miembros..." -msgid "Loading more pages…" -msgstr "Cargando más páginas…" - msgid "Loading more..." msgstr "Cargando más..." diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index 332b6dc8e01..5a281b8c04e 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -4266,9 +4266,6 @@ msgstr "Impossible de charger les environnements." msgid "Could not load image" msgstr "Impossible de charger l'image" -msgid "Could not load more pages." -msgstr "Impossible de charger d'autres pages." - msgid "Could not load plugins. Check your connection and try again." msgstr "Impossible de charger les plugins. Vérifiez votre connexion et réessayez." @@ -8459,9 +8456,6 @@ msgstr "Chargement des issues…" msgid "Loading members..." msgstr "Chargement des membres..." -msgid "Loading more pages…" -msgstr "Chargement d'autres pages…" - msgid "Loading more..." msgstr "Chargement..." diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index e735e5814d7..b0b12a270fd 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -4266,9 +4266,6 @@ msgstr "Tidak dapat memuat environment." msgid "Could not load image" msgstr "Gambar tidak dapat dimuat" -msgid "Could not load more pages." -msgstr "Tidak dapat memuat halaman lainnya." - msgid "Could not load plugins. Check your connection and try again." msgstr "Tidak dapat memuat plugin. Periksa koneksi Anda dan coba lagi." @@ -8459,9 +8456,6 @@ msgstr "Memuat issue…" msgid "Loading members..." msgstr "Memuat anggota..." -msgid "Loading more pages…" -msgstr "Memuat halaman lainnya…" - msgid "Loading more..." msgstr "Memuat lagi..." diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index 1eac2b9136c..25f9de0663f 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -4266,9 +4266,6 @@ msgstr "Impossibile caricare gli ambienti." msgid "Could not load image" msgstr "Impossibile caricare l'immagine" -msgid "Could not load more pages." -msgstr "Non è stato possibile caricare altre pagine." - msgid "Could not load plugins. Check your connection and try again." msgstr "Impossibile caricare i plugin. Controlla la connessione e riprova." @@ -8459,9 +8456,6 @@ msgstr "Caricamento delle issue…" msgid "Loading members..." msgstr "Caricamento dei membri..." -msgid "Loading more pages…" -msgstr "Caricamento di altre pagine…" - msgid "Loading more..." msgstr "Caricamento di altri..." diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index f39a66df72a..524ec1c732c 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -4266,9 +4266,6 @@ msgstr "環境を読み込めませんでした。" msgid "Could not load image" msgstr "画像を読み込めませんでした" -msgid "Could not load more pages." -msgstr "これ以上ページを読み込めませんでした。" - msgid "Could not load plugins. Check your connection and try again." msgstr "プラグインを読み込めませんでした。接続を確認して再試行してください。" @@ -8459,9 +8456,6 @@ msgstr "Issueを読み込み中…" msgid "Loading members..." msgstr "メンバーを読み込み中..." -msgid "Loading more pages…" -msgstr "さらにページを読み込み中…" - msgid "Loading more..." msgstr "さらに読み込み中..." diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index d253ad2cdf0..685a76ec488 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -4266,9 +4266,6 @@ msgstr "환경을 불러오지 못했습니다." msgid "Could not load image" msgstr "이미지를 불러올 수 없음" -msgid "Could not load more pages." -msgstr "페이지를 더 불러올 수 없습니다." - msgid "Could not load plugins. Check your connection and try again." msgstr "플러그인을 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요." @@ -8459,9 +8456,6 @@ msgstr "이슈 불러오는 중…" msgid "Loading members..." msgstr "멤버 불러오는 중..." -msgid "Loading more pages…" -msgstr "페이지를 더 불러오는 중…" - msgid "Loading more..." msgstr "더 불러오는 중..." diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index 1e4ca015b83..5f24f7aefac 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -4266,9 +4266,6 @@ msgstr "Kon omgevingen niet laden." msgid "Could not load image" msgstr "Afbeelding kon niet worden geladen" -msgid "Could not load more pages." -msgstr "Kon geen pagina's meer laden." - msgid "Could not load plugins. Check your connection and try again." msgstr "Plugins konden niet worden geladen. Controleer je verbinding en probeer het opnieuw." @@ -8459,9 +8456,6 @@ msgstr "Issues laden…" msgid "Loading members..." msgstr "Leden laden..." -msgid "Loading more pages…" -msgstr "Meer pagina's laden…" - msgid "Loading more..." msgstr "Meer laden..." diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index 4c4385bb344..77f8488dede 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -4266,9 +4266,6 @@ msgstr "Nie udało się wczytać środowisk." msgid "Could not load image" msgstr "Nie udało się załadować obrazu" -msgid "Could not load more pages." -msgstr "Nie udało się wczytać kolejnych stron." - msgid "Could not load plugins. Check your connection and try again." msgstr "Nie udało się wczytać wtyczek. Sprawdź połączenie i spróbuj ponownie." @@ -8459,9 +8456,6 @@ msgstr "Wczytywanie zgłoszeń…" msgid "Loading members..." msgstr "Wczytywanie członków..." -msgid "Loading more pages…" -msgstr "Wczytywanie kolejnych stron…" - msgid "Loading more..." msgstr "Wczytywanie kolejnych..." diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index eae9b813b4c..b132f9253a2 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -4266,9 +4266,6 @@ msgstr "Não foi possível carregar os ambientes." msgid "Could not load image" msgstr "Não foi possível carregar a imagem" -msgid "Could not load more pages." -msgstr "Não foi possível carregar mais páginas." - msgid "Could not load plugins. Check your connection and try again." msgstr "Não foi possível carregar os plugins. Verifique sua conexão e tente novamente." @@ -8459,9 +8456,6 @@ msgstr "Carregando as issues…" msgid "Loading members..." msgstr "Carregando membros..." -msgid "Loading more pages…" -msgstr "Carregando mais páginas…" - msgid "Loading more..." msgstr "Carregando mais..." diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index e3f5b8d25ba..740259b6445 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -4266,9 +4266,6 @@ msgstr "Не удалось загрузить окружения." msgid "Could not load image" msgstr "Не удалось загрузить изображение" -msgid "Could not load more pages." -msgstr "Не удалось загрузить другие страницы." - msgid "Could not load plugins. Check your connection and try again." msgstr "Не удалось загрузить плагины. Проверьте подключение и попробуйте снова." @@ -8459,9 +8456,6 @@ msgstr "Загрузка задач…" msgid "Loading members..." msgstr "Загрузка участников..." -msgid "Loading more pages…" -msgstr "Загрузка других страниц…" - msgid "Loading more..." msgstr "Загружаем ещё..." diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index c74459297fa..dcc795304ba 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -4266,9 +4266,6 @@ msgstr "Ortamlar yüklenemedi." msgid "Could not load image" msgstr "Görsel yüklenemedi" -msgid "Could not load more pages." -msgstr "Daha fazla sayfa yüklenemedi." - msgid "Could not load plugins. Check your connection and try again." msgstr "Eklentiler yüklenemedi. Bağlantınızı kontrol edip tekrar deneyin." @@ -8459,9 +8456,6 @@ msgstr "Issue'lar yükleniyor…" msgid "Loading members..." msgstr "Üyeler yükleniyor..." -msgid "Loading more pages…" -msgstr "Daha fazla sayfa yükleniyor…" - msgid "Loading more..." msgstr "Daha fazlası yükleniyor..." diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index afdef69e523..13d1ed626c3 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -4266,9 +4266,6 @@ msgstr "Không thể tải environments." msgid "Could not load image" msgstr "Không thể tải ảnh" -msgid "Could not load more pages." -msgstr "Không thể tải thêm trang." - msgid "Could not load plugins. Check your connection and try again." msgstr "Không tải được plugin. Hãy kiểm tra kết nối và thử lại." @@ -8459,9 +8456,6 @@ msgstr "Đang tải issue…" msgid "Loading members..." msgstr "Đang tải thành viên..." -msgid "Loading more pages…" -msgstr "Đang tải thêm trang…" - msgid "Loading more..." msgstr "Đang tải thêm..." diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index 81c58b2a5b1..977ee70e8d2 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -4266,9 +4266,6 @@ msgstr "无法加载环境。" msgid "Could not load image" msgstr "无法加载图片" -msgid "Could not load more pages." -msgstr "无法加载更多页面。" - msgid "Could not load plugins. Check your connection and try again." msgstr "无法加载插件。请检查网络连接后重试。" @@ -8459,9 +8456,6 @@ msgstr "正在加载议题…" msgid "Loading members..." msgstr "正在加载成员..." -msgid "Loading more pages…" -msgstr "正在加载更多页面…" - msgid "Loading more..." msgstr "正在加载更多..." diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index 0327353fe1b..c407902b534 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -4266,9 +4266,6 @@ msgstr "無法載入環境。" msgid "Could not load image" msgstr "無法載入圖片" -msgid "Could not load more pages." -msgstr "無法載入更多頁面。" - msgid "Could not load plugins. Check your connection and try again." msgstr "無法載入外掛。請檢查連線後再試一次。" @@ -8459,9 +8456,6 @@ msgstr "正在載入議題…" msgid "Loading members..." msgstr "正在載入成員..." -msgid "Loading more pages…" -msgstr "正在載入更多頁面…" - msgid "Loading more..." msgstr "正在載入更多..."