From 8349a161e68fa757aff85b7b97b525b2668ab090 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:17:43 -0400 Subject: [PATCH 1/8] Publish Content database intake capabilities --- templates/content/actions/_database-utils.ts | 2 +- .../content/actions/add-database-item.ts | 9 + .../actions/describe-content-database.ts | 66 +++++++ .../actions/list-content-databases.db.test.ts | 174 +++++++++++++++++- .../content/actions/list-content-databases.ts | 81 +++++++- .../content/actions/set-document-property.ts | 9 + .../actions/submit-content-database-form.ts | 9 + templates/content/actions/update-document.ts | 9 + .../app/lib/content-command-search.test.ts | 6 + templates/content/server/agent-card.test.ts | 88 ++++++++- .../content/server/plugins/agent-chat.spec.ts | 16 ++ .../content/server/plugins/agent-chat.ts | 3 + templates/content/shared/api.ts | 8 + 13 files changed, 461 insertions(+), 19 deletions(-) create mode 100644 templates/content/actions/describe-content-database.ts diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index bc87627e51..664052d0aa 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -502,7 +502,7 @@ export async function resolveContentDatabaseRead(args: { canRead = false; } } - if (!canRead) throw new Error(`Database "${databaseId}" not found`); + if (!canRead) throw new Error("Content database not found."); if (database.deletedAt) { return { diff --git a/templates/content/actions/add-database-item.ts b/templates/content/actions/add-database-item.ts index 363d6989b1..66a02064c1 100644 --- a/templates/content/actions/add-database-item.ts +++ b/templates/content/actions/add-database-item.ts @@ -25,6 +25,15 @@ import { nanoid, normalizedValueJson } from "./_property-utils.js"; export default defineAction({ description: "Add a page item to a content database table.", + publicAgent: { + expose: true, + readOnly: false, + requiresAuth: true, + isConsequential: true, + title: "Add Content Database Item", + description: + "Delegate creation of one page item in an existing Content database.", + }, schema: z.object({ databaseId: z.string().describe("Database ID"), title: z.string().optional().describe("New row page title"), diff --git a/templates/content/actions/describe-content-database.ts b/templates/content/actions/describe-content-database.ts new file mode 100644 index 0000000000..ba32eb9032 --- /dev/null +++ b/templates/content/actions/describe-content-database.ts @@ -0,0 +1,66 @@ +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; + +import type { + ContentDatabaseDescriptionResponse, + ContentDatabaseUnavailableResponse, +} from "../shared/api.js"; +import { + getContentDatabaseResponse, + resolveContentDatabaseRead, +} from "./_database-utils.js"; +import listContentDatabases from "./list-content-databases.js"; + +export default defineAction({ + description: + "Describe one exact ordinary Content database, including its live metadata, views, and property schema but not its rows. Resolve the stable database or document ID with list-content-databases first.", + schema: z + .object({ + databaseId: z.string().min(1).optional().describe("Exact database ID"), + documentId: z + .string() + .min(1) + .optional() + .describe("Exact database document/page ID"), + }) + .refine( + (input) => Boolean(input.databaseId) !== Boolean(input.documentId), + "Provide exactly one of databaseId or documentId.", + ), + http: { method: "GET" }, + readOnly: true, + publicAgent: { expose: true, readOnly: true, requiresAuth: true }, + run: async ({ + databaseId, + documentId, + }): Promise< + ContentDatabaseDescriptionResponse | ContentDatabaseUnavailableResponse + > => { + let selection: Awaited>; + try { + selection = await listContentDatabases.run({ databaseId, documentId }); + } catch { + throw new Error("Content database not found."); + } + const selected = selection.databases[0]; + if (!selected) throw new Error("Content database not found."); + + const resolved = await resolveContentDatabaseRead({ + databaseId: selected.databaseId, + }); + if (!resolved.available) return resolved; + if (resolved.database.systemRole) { + throw new Error("Content database not found."); + } + + const response = await getContentDatabaseResponse(resolved.database.id, { + limit: 1, + database: resolved.database, + }); + return { + database: response.database, + contextPath: response.contextPath ?? [], + properties: response.properties, + }; + }, +}); diff --git a/templates/content/actions/list-content-databases.db.test.ts b/templates/content/actions/list-content-databases.db.test.ts index b945c5da31..caa77ea5eb 100644 --- a/templates/content/actions/list-content-databases.db.test.ts +++ b/templates/content/actions/list-content-databases.db.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { runWithRequestContext } from "@agent-native/core/server"; +import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; const TEST_DB_PATH = join( @@ -14,6 +15,7 @@ type Schema = typeof import("../server/db/schema.js"); let getDb: () => any; let schema: Schema; let listContentDatabasesAction: typeof import("./list-content-databases.js").default; +let describeContentDatabaseAction: typeof import("./describe-content-database.js").default; const OWNER = "owner@example.com"; @@ -24,6 +26,9 @@ beforeAll(async () => { schema = dbModule.schema; listContentDatabasesAction = (await import("./list-content-databases.js")) .default; + describeContentDatabaseAction = ( + await import("./describe-content-database.js") + ).default; const plugin = (await import("../server/plugins/db.js")).default; await plugin(undefined as any); }, 60000); @@ -38,15 +43,22 @@ async function createDatabaseDocument(args: { documentId: string; databaseId: string; title: string; + description?: string; + spaceId?: string; + systemRole?: string; + ownerEmail?: string; }) { const db = getDb(); const now = new Date().toISOString(); + const ownerEmail = args.ownerEmail ?? OWNER; await db.insert(schema.documents).values({ id: args.documentId, - ownerEmail: OWNER, + ownerEmail, + spaceId: args.spaceId, parentId: null, title: args.title, content: "", + description: args.description ?? "", position: 1, visibility: "private", createdAt: now, @@ -54,9 +66,11 @@ async function createDatabaseDocument(args: { }); await db.insert(schema.contentDatabases).values({ id: args.databaseId, - ownerEmail: OWNER, + ownerEmail, + spaceId: args.spaceId, documentId: args.documentId, title: args.title, + systemRole: args.systemRole, }); } @@ -76,13 +90,169 @@ describe("list-content-databases", () => { { databaseId: "db-cmdk", documentId: "db-doc-cmdk", + spaceId: null, title: "CmdK Database TestDB", + description: "", }, ], }); }); }); + it("searches user-authored descriptions and returns live identity metadata", async () => { + await createDatabaseDocument({ + documentId: "db-doc-described", + databaseId: "db-described", + title: "Intake Queue", + description: "Collects requests for editorial design review", + spaceId: "space-creative", + }); + await getDb() + .update(schema.contentDatabases) + .set({ title: "Stale database title" }) + .where(eq(schema.contentDatabases.id, "db-described")); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + await expect( + listContentDatabasesAction.run({ query: "EDITORIAL DESIGN" }), + ).resolves.toEqual({ + databases: [ + { + databaseId: "db-described", + documentId: "db-doc-described", + spaceId: "space-creative", + title: "Intake Queue", + description: "Collects requests for editorial design review", + }, + ], + }); + }); + }); + + it("resolves exact IDs and titles within an exact space", async () => { + await createDatabaseDocument({ + documentId: "db-doc-exact", + databaseId: "db-exact", + title: "Product Feedback", + description: "Captures product feedback", + spaceId: "space-product", + }); + await createDatabaseDocument({ + documentId: "db-doc-exact-other-space", + databaseId: "db-exact-other-space", + title: "Product Feedback", + spaceId: "space-other", + }); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + const expected = { + databases: [ + { + databaseId: "db-exact", + documentId: "db-doc-exact", + spaceId: "space-product", + title: "Product Feedback", + description: "Captures product feedback", + }, + ], + }; + await expect( + listContentDatabasesAction.run({ databaseId: "db-exact" }), + ).resolves.toEqual(expected); + await expect( + listContentDatabasesAction.run({ documentId: "db-doc-exact" }), + ).resolves.toEqual(expected); + await expect( + listContentDatabasesAction.run({ + spaceId: "space-product", + title: "product feedback", + }), + ).resolves.toEqual(expected); + + const description = await describeContentDatabaseAction.run({ + databaseId: "db-exact", + }); + expect(description).toMatchObject({ + database: { + id: "db-exact", + documentId: "db-doc-exact", + title: "Product Feedback", + description: "Captures product feedback", + }, + properties: [], + }); + expect(description).not.toHaveProperty("items"); + }); + }); + + it("fails closed when exact title resolution is missing or ambiguous", async () => { + await createDatabaseDocument({ + documentId: "db-doc-ambiguous-a", + databaseId: "db-ambiguous-a", + title: "Shared Intake", + }); + await createDatabaseDocument({ + documentId: "db-doc-ambiguous-b", + databaseId: "db-ambiguous-b", + title: "Shared Intake", + }); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + await expect( + listContentDatabasesAction.run({ title: "Missing Intake" }), + ).rejects.toThrow(/No accessible Content database matched/); + await expect( + listContentDatabasesAction.run({ title: "Shared Intake" }), + ).rejects.toThrow(/ambiguous across 2 accessible Content databases/); + await expect( + listContentDatabasesAction.run({ title: " " }), + ).rejects.toThrow(); + }); + }); + + it("does not disclose system or inaccessible databases", async () => { + await createDatabaseDocument({ + documentId: "db-doc-system", + databaseId: "db-system", + title: "System Files", + systemRole: "files", + }); + await createDatabaseDocument({ + documentId: "db-doc-private-other", + databaseId: "db-private-other", + title: "Private Other", + ownerEmail: "other@example.com", + }); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + await expect( + listContentDatabasesAction.run({ databaseId: "db-system" }), + ).rejects.toThrow(/No accessible Content database matched/); + await expect( + listContentDatabasesAction.run({ databaseId: "db-private-other" }), + ).rejects.toThrow(/No accessible Content database matched/); + await expect( + describeContentDatabaseAction.run({ databaseId: "db-system" }), + ).rejects.toThrow("Content database not found."); + + let inaccessibleError: unknown; + try { + await describeContentDatabaseAction.run({ + documentId: "db-doc-private-other", + }); + } catch (error) { + inaccessibleError = error; + } + expect(inaccessibleError).toBeInstanceOf(Error); + expect((inaccessibleError as Error).message).toBe( + "Content database not found.", + ); + expect((inaccessibleError as Error).message).not.toContain( + "db-private-other", + ); + }); + }); + it("excludes a database when its document id is passed (no source attached yet)", async () => { await createDatabaseDocument({ documentId: "db-doc-self", diff --git a/templates/content/actions/list-content-databases.ts b/templates/content/actions/list-content-databases.ts index c334928118..2b736546de 100644 --- a/templates/content/actions/list-content-databases.ts +++ b/templates/content/actions/list-content-databases.ts @@ -1,6 +1,6 @@ import { defineAction } from "@agent-native/core"; import { accessFilter } from "@agent-native/core/sharing"; -import { and, asc, eq, isNull, ne, sql } from "drizzle-orm"; +import { and, asc, eq, isNull, ne, or, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -13,8 +13,29 @@ function escapeLike(s: string): string { export default defineAction({ description: - "List the content databases the user can access (owned, shared, or org-shared — matching the sidebar) so any of them can be used as a local-table source. Optionally filters by title or excludes one database (e.g. the one being configured).", + "Discover ordinary Content databases the user can access from their live title and user-authored description. Returns stable database, document, and space IDs. Use exact filters before reading a selected database's schema.", schema: z.object({ + spaceId: z + .string() + .min(1) + .optional() + .describe("Exact Content space ID to search within."), + databaseId: z + .string() + .min(1) + .optional() + .describe("Exact Content database ID to resolve."), + documentId: z + .string() + .min(1) + .optional() + .describe("Exact Content database document/page ID to resolve."), + title: z + .string() + .trim() + .min(1) + .optional() + .describe("Exact live database title to resolve, case-insensitively."), excludeDatabaseId: z .string() .optional() @@ -23,7 +44,10 @@ export default defineAction({ .array(z.string()) .optional() .describe("Database ids to omit from the results."), - query: z.string().optional().describe("Optional title search text."), + query: z + .string() + .optional() + .describe("Optional title or user-authored description search text."), limit: z.coerce .number() .int() @@ -34,10 +58,12 @@ export default defineAction({ }), http: { method: "GET" }, readOnly: true, + publicAgent: { expose: true, readOnly: true, requiresAuth: true }, run: async (args): Promise => { const db = getDb(); const query = args.query?.trim(); const pattern = query ? `%${escapeLike(query.toLowerCase())}%` : null; + const exactTitle = args.title?.toLowerCase(); const excludedDatabaseIds = new Set( [ args.excludeDatabaseId?.trim(), @@ -51,6 +77,8 @@ export default defineAction({ id: schema.contentDatabases.id, documentId: schema.contentDatabases.documentId, title: schema.documents.title, + description: schema.documents.description, + spaceId: schema.contentDatabases.spaceId, }) .from(schema.contentDatabases) .innerJoin( @@ -63,6 +91,19 @@ export default defineAction({ isNull(schema.documents.trashedAt), documentDiscoveryFilter(), isNull(schema.contentDatabases.deletedAt), + isNull(schema.contentDatabases.systemRole), + args.spaceId + ? eq(schema.contentDatabases.spaceId, args.spaceId) + : undefined, + args.databaseId + ? eq(schema.contentDatabases.id, args.databaseId) + : undefined, + args.documentId + ? eq(schema.contentDatabases.documentId, args.documentId) + : undefined, + exactTitle + ? sql`lower(${schema.documents.title}) = ${exactTitle}` + : undefined, excludedDatabaseIds.size === 1 ? ne( schema.contentDatabases.id, @@ -70,15 +111,16 @@ export default defineAction({ ) : undefined, pattern - ? sql`lower(${schema.documents.title}) LIKE ${pattern} ESCAPE '\\'` + ? or( + sql`lower(${schema.documents.title}) LIKE ${pattern} ESCAPE '\\'`, + sql`lower(${schema.documents.description}) LIKE ${pattern} ESCAPE '\\'`, + ) : undefined, ), ) .orderBy(asc(schema.documents.position)); - const rows = args.limit - ? await queryBuilder.limit(args.limit) - : await queryBuilder; + const rows = await queryBuilder; const localTableSources = excludedDatabaseIds.size > 0 @@ -107,20 +149,41 @@ export default defineAction({ return false; }; - const databases = rows + const visibleRows = rows // Exclusion ids may be database ids OR database document ids — the // settings panel only has the document id before any source exists. .filter( (row) => !excludedDatabaseIds.has(row.documentId) && !sourceChainIncludesExcludedDatabase(row.id), - ) + ); + + if ( + (args.databaseId || args.documentId || exactTitle) && + visibleRows.length !== 1 + ) { + const selector = args.databaseId + ? `database ID "${args.databaseId}"` + : args.documentId + ? `document ID "${args.documentId}"` + : `title "${args.title?.trim()}"`; + throw new Error( + visibleRows.length === 0 + ? `No accessible Content database matched exact ${selector}.` + : `Exact ${selector} is ambiguous across ${visibleRows.length} accessible Content databases.`, + ); + } + + const databases = visibleRows + .slice(0, args.limit ?? visibleRows.length) .map((row) => ({ databaseId: row.id, documentId: row.documentId, + spaceId: row.spaceId, // The document's live title (matches the sidebar) rather than the // possibly-stale content_databases.title. title: row.title ?? "Untitled database", + description: row.description, })); return { databases }; diff --git a/templates/content/actions/set-document-property.ts b/templates/content/actions/set-document-property.ts index cafd59097f..7b83e91e3c 100644 --- a/templates/content/actions/set-document-property.ts +++ b/templates/content/actions/set-document-property.ts @@ -24,6 +24,15 @@ import { export default defineAction({ description: "Set a Notion-style property value on a document.", + publicAgent: { + expose: true, + readOnly: false, + requiresAuth: true, + isConsequential: true, + title: "Set Content Document Property", + description: + "Delegate one property update on an existing Content database document.", + }, schema: z.object({ documentId: z.string().describe("Document ID (required)"), databaseId: z diff --git a/templates/content/actions/submit-content-database-form.ts b/templates/content/actions/submit-content-database-form.ts index aad7e77e17..86b1f1b428 100644 --- a/templates/content/actions/submit-content-database-form.ts +++ b/templates/content/actions/submit-content-database-form.ts @@ -195,6 +195,15 @@ function resolveSubmittedProperties( export default defineAction({ description: "Submit one row through a Content database form. Validates that form's required questions, resolves option labels safely, writes the title, Blocks, and property values atomically, verifies the saved row, and returns its exact page link.", + publicAgent: { + expose: true, + readOnly: false, + requiresAuth: true, + isConsequential: true, + title: "Submit Content Database Form", + description: + "Delegate a validated, atomic submission to an existing Content database form.", + }, schema: submitContentDatabaseFormSchema, mcpApp: { compactCatalog: true, diff --git a/templates/content/actions/update-document.ts b/templates/content/actions/update-document.ts index 2b1ea6dd3e..87aa90a4d7 100644 --- a/templates/content/actions/update-document.ts +++ b/templates/content/actions/update-document.ts @@ -286,6 +286,15 @@ export function isStaleBuilderImageSourceComponentSave(args: { export default defineAction({ description: "Update an existing document's title, content, icon, or favorite status.", + publicAgent: { + expose: true, + readOnly: false, + requiresAuth: true, + isConsequential: true, + title: "Update Content Document", + description: + "Delegate a sparse update to an existing Content document while preserving omitted fields.", + }, schema: z.object({ id: z.string().optional().describe("Document ID (required)"), title: z.string().optional().describe("New title"), diff --git a/templates/content/app/lib/content-command-search.test.ts b/templates/content/app/lib/content-command-search.test.ts index 84636b7b83..8d78c818c5 100644 --- a/templates/content/app/lib/content-command-search.test.ts +++ b/templates/content/app/lib/content-command-search.test.ts @@ -40,12 +40,16 @@ describe("content command search", () => { { databaseId: "db-1", documentId: "db-doc-1", + spaceId: null, title: "Launch calendar", + description: "", }, { databaseId: "db-2", documentId: "db-doc-2", + spaceId: null, title: "Ideas", + description: "", }, ], }); @@ -81,7 +85,9 @@ describe("content command search", () => { { databaseId: "db-1", documentId: "db-doc-1", + spaceId: null, title: "Launch calendar", + description: "", }, ], }); diff --git a/templates/content/server/agent-card.test.ts b/templates/content/server/agent-card.test.ts index 6c4432906b..dca0b0c05c 100644 --- a/templates/content/server/agent-card.test.ts +++ b/templates/content/server/agent-card.test.ts @@ -6,6 +6,12 @@ import { loadActionsFromStaticRegistry } from "@agent-native/core/server"; import { generateActionRegistryForProject } from "@agent-native/core/vite"; import { describe, expect, it } from "vitest"; +import { + buildAuthenticatedAgentA2ASkills, + filterDirectA2AActions, +} from "../../../packages/core/src/server/agent-chat/action-filters-a2a.js"; +import { dispatchIntegrationRoutingHint } from "../../../packages/dispatch/src/server/lib/dispatch-routing.js"; + const projectRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", @@ -24,17 +30,21 @@ const REQUIRED_CONTENT_ACTIONS = [ const ACTION_REGISTRY_TEST_TIMEOUT_MS = 60_000; +async function loadContentActions() { + generateActionRegistryForProject(projectRoot); + + const registryUrl = + pathToFileURL(path.join(projectRoot, ".generated/actions-registry.ts")) + .href + `?cacheBust=${Date.now()}`; + const { default: modules } = await import(registryUrl); + return loadActionsFromStaticRegistry(modules); +} + describe("content agent card", () => { it( "advertises content domain actions from the generated static registry", async () => { - generateActionRegistryForProject(projectRoot); - - const registryUrl = - pathToFileURL(path.join(projectRoot, ".generated/actions-registry.ts")) - .href + `?cacheBust=${Date.now()}`; - const { default: modules } = await import(registryUrl); - const actions = loadActionsFromStaticRegistry(modules); + const actions = await loadContentActions(); const card = generateAgentCard( { name: "Content", @@ -57,4 +67,68 @@ describe("content agent card", () => { }, ACTION_REGISTRY_TEST_TIMEOUT_MS, ); + + it( + "publishes bounded reads and message-only intake mutations for generic Dispatch delegation", + async () => { + const actions = await loadContentActions(); + const externalAgentOptions = { + connectorCatalog: [ + "list-content-databases", + "describe-content-database", + ], + }; + const skills = buildAuthenticatedAgentA2ASkills( + actions, + externalAgentOptions, + ); + const skillsById = new Map(skills.map((skill) => [skill.id, skill])); + + for (const actionName of [ + "list-content-databases", + "describe-content-database", + ]) { + expect(skillsById.get(actionName)).toMatchObject({ readOnly: true }); + expect(skillsById.get(actionName)?.inputSchema).toBeDefined(); + } + expect(skillsById.get("list-content-databases")?.description).toContain( + "user-authored description", + ); + + for (const actionName of [ + "submit-content-database-form", + "add-database-item", + "update-document", + "set-document-property", + ]) { + expect(skillsById.get(actionName)).toMatchObject({ readOnly: false }); + expect(skillsById.get(actionName)?.inputSchema).toBeUndefined(); + } + + const directlyInvocable = filterDirectA2AActions( + actions, + externalAgentOptions, + ); + expect(directlyInvocable).toHaveProperty("list-content-databases"); + expect(directlyInvocable).toHaveProperty("describe-content-database"); + expect(directlyInvocable).not.toHaveProperty("get-content-database"); + expect(directlyInvocable).not.toHaveProperty( + "submit-content-database-form", + ); + expect(directlyInvocable).not.toHaveProperty("update-document"); + + const intakeHint = dispatchIntegrationRoutingHint( + "Add this design request to the editorial intake database", + ); + expect(intakeHint?.targetAgent).toBeUndefined(); + expect(intakeHint?.instruction).toContain("discovered app capabilities"); + + expect( + dispatchIntegrationRoutingHint( + "Design a visual mockup for the editorial intake screen", + ), + ).toMatchObject({ targetAgent: "design" }); + }, + ACTION_REGISTRY_TEST_TIMEOUT_MS, + ); }); diff --git a/templates/content/server/plugins/agent-chat.spec.ts b/templates/content/server/plugins/agent-chat.spec.ts index b8488d7c50..428d5e916b 100644 --- a/templates/content/server/plugins/agent-chat.spec.ts +++ b/templates/content/server/plugins/agent-chat.spec.ts @@ -49,4 +49,20 @@ describe("Content agent chat plugin", () => { "Do not call view-screen at the start of a turn or repeatedly", ); }); + + it("keeps the direct authenticated A2A surface to bounded database reads", async () => { + await import("./agent-chat.js"); + + expect(mocks.createAgentChatPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + appId: "content", + mcp: { + connectorCatalog: [ + "list-content-databases", + "describe-content-database", + ], + }, + }), + ); + }); }); diff --git a/templates/content/server/plugins/agent-chat.ts b/templates/content/server/plugins/agent-chat.ts index af62a3457a..97e9298006 100644 --- a/templates/content/server/plugins/agent-chat.ts +++ b/templates/content/server/plugins/agent-chat.ts @@ -34,6 +34,9 @@ export default createAgentChatPlugin({ durableBackgroundRuns: true, actions: loadActionsFromStaticRegistry(actionsRegistry), initialToolNames: INITIAL_TOOL_NAMES, + mcp: { + connectorCatalog: ["list-content-databases", "describe-content-database"], + }, anonymousOwner: resolvePublicViewerOwner, extraContext: publicDocumentExtraContext, // Enable sandboxed JavaScript execution so Content agents can fetch, diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 2500ccf38e..e3b68572ca 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -1001,7 +1001,15 @@ export interface ChangeContentDatabaseSourceRoleRequest { export interface ContentDatabaseSummary { databaseId: string; documentId: string; + spaceId: string | null; title: string; + description: string; +} + +export interface ContentDatabaseDescriptionResponse { + database: ContentDatabase; + contextPath: ContentContextPathEntry[]; + properties: DocumentProperty[]; } export interface ListContentDatabasesResponse { From 031e6bd80f3a17ed8c18b6de80d058866c51fe94 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:27:17 -0400 Subject: [PATCH 2/8] Fix Content capability CI coverage --- .../content-a2a-capabilities.spec.ts | 96 +++++++++++++++++++ templates/content/actions/_database-utils.ts | 2 +- templates/content/parity/matrix.md | 2 +- templates/content/parity/matrix.ts | 8 +- templates/content/server/agent-card.test.ts | 70 -------------- 5 files changed, 105 insertions(+), 73 deletions(-) create mode 100644 packages/core/src/server/agent-chat/content-a2a-capabilities.spec.ts diff --git a/packages/core/src/server/agent-chat/content-a2a-capabilities.spec.ts b/packages/core/src/server/agent-chat/content-a2a-capabilities.spec.ts new file mode 100644 index 0000000000..a64ebab010 --- /dev/null +++ b/packages/core/src/server/agent-chat/content-a2a-capabilities.spec.ts @@ -0,0 +1,96 @@ +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { dispatchIntegrationRoutingHint } from "../../../../dispatch/src/server/lib/dispatch-routing.js"; +import { generateActionRegistryForProject } from "../../vite/action-types-plugin.js"; +import { loadActionsFromStaticRegistry } from "../action-discovery.js"; +import { + buildAuthenticatedAgentA2ASkills, + filterDirectA2AActions, +} from "./action-filters-a2a.js"; + +const contentProjectRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../../../templates/content", +); + +const ACTION_REGISTRY_TEST_TIMEOUT_MS = 60_000; + +async function loadContentActions() { + generateActionRegistryForProject(contentProjectRoot); + + const registryUrl = + pathToFileURL( + path.join(contentProjectRoot, ".generated/actions-registry.ts"), + ).href + `?cacheBust=${Date.now()}`; + const { default: modules } = await import(registryUrl); + return loadActionsFromStaticRegistry(modules); +} + +describe("Content authenticated A2A capabilities", () => { + it( + "publishes bounded reads and message-only intake mutations for generic Dispatch delegation", + async () => { + const actions = await loadContentActions(); + const externalAgentOptions = { + connectorCatalog: [ + "list-content-databases", + "describe-content-database", + ], + }; + const skills = buildAuthenticatedAgentA2ASkills( + actions, + externalAgentOptions, + ); + const skillsById = new Map(skills.map((skill) => [skill.id, skill])); + + for (const actionName of [ + "list-content-databases", + "describe-content-database", + ]) { + expect(skillsById.get(actionName)).toMatchObject({ readOnly: true }); + expect(skillsById.get(actionName)?.inputSchema).toBeDefined(); + } + expect(skillsById.get("list-content-databases")?.description).toContain( + "user-authored description", + ); + + for (const actionName of [ + "submit-content-database-form", + "add-database-item", + "update-document", + "set-document-property", + ]) { + expect(skillsById.get(actionName)).toMatchObject({ readOnly: false }); + expect(skillsById.get(actionName)?.inputSchema).toBeUndefined(); + } + + const directlyInvocable = filterDirectA2AActions( + actions, + externalAgentOptions, + ); + expect(directlyInvocable).toHaveProperty("list-content-databases"); + expect(directlyInvocable).toHaveProperty("describe-content-database"); + expect(directlyInvocable).not.toHaveProperty("get-content-database"); + expect(directlyInvocable).not.toHaveProperty( + "submit-content-database-form", + ); + expect(directlyInvocable).not.toHaveProperty("update-document"); + + const intakeHint = dispatchIntegrationRoutingHint( + "Add this design request to the editorial intake database", + ); + expect(intakeHint?.targetAgent).toBeUndefined(); + expect(intakeHint?.instruction).toContain("discovered app capabilities"); + + expect( + dispatchIntegrationRoutingHint( + "Design a visual mockup for the editorial intake screen", + ), + ).toMatchObject({ targetAgent: "design" }); + }, + ACTION_REGISTRY_TEST_TIMEOUT_MS, + ); +}); diff --git a/templates/content/actions/_database-utils.ts b/templates/content/actions/_database-utils.ts index 664052d0aa..bc87627e51 100644 --- a/templates/content/actions/_database-utils.ts +++ b/templates/content/actions/_database-utils.ts @@ -502,7 +502,7 @@ export async function resolveContentDatabaseRead(args: { canRead = false; } } - if (!canRead) throw new Error("Content database not found."); + if (!canRead) throw new Error(`Database "${databaseId}" not found`); if (database.deletedAt) { return { diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index 5c8d6c325f..cd8cd6e86b 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -6,7 +6,7 @@ This generated matrix tracks whether high-value Content UI operations use the sa | -------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | -------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------ | | comments.threads | comments | List, add, reply, resolve, reopen, and delete comment threads | action-backed | `add-comment`, `delete-comment`, `list-comments`, `update-comment` | `app/components/editor/CommentsSidebar.tsx`, `app/hooks/use-comments.ts` | Comment threads, replies, anchors, mentions, resolution state, and deletion are stored through comment actions. | - | - | P0 | seeded | - | - | - | | database.form-submissions | database | Submit public database forms as new rows | action-backed | `submit-content-database-form` | `app/components/editor/database/FormView.tsx` | A validated form submission atomically creates a database row document and its editable property values. | - | - | P0 | covered | `actions/submit-content-database-form.db.test.ts` | - | - | -| database.lifecycle-and-trash | database | Create, soft-delete, restore, list, and inspect content databases | action-backed | `create-content-database`, `create-inline-content-database`, `delete-content-database`, `get-content-database`, `list-content-databases`, `list-trashed-content-databases`, `restore-content-database` | `app/components/editor/SlashCommandMenu.tsx`, `app/hooks/use-content-database.ts`, `app/hooks/use-documents.ts` | Database pages and database records are created, read, soft-deleted, restored, and listed. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts` | `database-source-scope` | - | +| database.lifecycle-and-trash | database | Create, soft-delete, restore, list, and inspect content databases | action-backed | `create-content-database`, `create-inline-content-database`, `delete-content-database`, `describe-content-database`, `get-content-database`, `list-content-databases`, `list-trashed-content-databases`, `restore-content-database` | `app/components/editor/SlashCommandMenu.tsx`, `app/hooks/use-content-database.ts`, `app/hooks/use-documents.ts` | Database pages and database records are created, read, soft-deleted, restored, and listed. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/list-content-databases.db.test.ts`, `server/plugins/agent-chat.spec.ts`, `../../packages/core/src/server/agent-chat/content-a2a-capabilities.spec.ts` | `database-source-scope` | - | | database.private-preview-drafts | database | Persist and reconcile a user's private database-page preview draft | action-backed | `get-preview-document-draft`, `update-preview-document-draft` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-documents.ts` | A user's private preview draft is read, saved, conflict-checked, or deleted without changing the shared database page until the normal save flow applies it. | These per-user editor-state actions are intentionally hidden from agent tools because preview drafts are a private UI recovery mechanism. | - | P1 | covered | `actions/preview-document-draft.db.test.ts` | - | - | | database.properties-and-view-config | database | Configure properties, values, ordering, and saved views | action-backed | `configure-document-property`, `delete-document-property`, `duplicate-document-property`, `get-content-database-personal-view`, `list-document-properties`, `reorder-document-property`, `set-document-property`, `update-content-database-personal-view`, `update-content-database-view` | `app/components/editor/DocumentProperties.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-document-properties.ts` | Property schemas, property values, property order, filters, sorts, grouping, hidden columns, view type, and view settings are stored. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | | database.rows | database | Add, duplicate, move, open, edit, and remove database rows | action-backed | `add-database-item`, `upsert-database-item-by-key`, `remove-database-items`, `duplicate-database-items`, `duplicate-database-item`, `migrate-content-database-rows`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row memberships and ordering are created, duplicated, moved, edited, and removed without deleting the backing page; bounded migrations atomically update row bodies and properties through the same canonical data model. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `actions/migrate-content-database-rows.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | diff --git a/templates/content/parity/matrix.ts b/templates/content/parity/matrix.ts index 56a66a2e98..939dc24926 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -275,6 +275,7 @@ export const parityMatrix: ParityRow[] = [ "create-content-database", "create-inline-content-database", "delete-content-database", + "describe-content-database", "get-content-database", "list-content-databases", "list-trashed-content-databases", @@ -285,7 +286,12 @@ export const parityMatrix: ParityRow[] = [ spinePriority: "P0", testCoverage: "covered", followUpPR: null, - coverageRefs: ["actions/content-database-lifecycle.db.test.ts"], + coverageRefs: [ + "actions/content-database-lifecycle.db.test.ts", + "actions/list-content-databases.db.test.ts", + "server/plugins/agent-chat.spec.ts", + "../../packages/core/src/server/agent-chat/content-a2a-capabilities.spec.ts", + ], evalScenarioIds: ["database-source-scope"], }, { diff --git a/templates/content/server/agent-card.test.ts b/templates/content/server/agent-card.test.ts index dca0b0c05c..fc5e68463d 100644 --- a/templates/content/server/agent-card.test.ts +++ b/templates/content/server/agent-card.test.ts @@ -6,12 +6,6 @@ import { loadActionsFromStaticRegistry } from "@agent-native/core/server"; import { generateActionRegistryForProject } from "@agent-native/core/vite"; import { describe, expect, it } from "vitest"; -import { - buildAuthenticatedAgentA2ASkills, - filterDirectA2AActions, -} from "../../../packages/core/src/server/agent-chat/action-filters-a2a.js"; -import { dispatchIntegrationRoutingHint } from "../../../packages/dispatch/src/server/lib/dispatch-routing.js"; - const projectRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", @@ -67,68 +61,4 @@ describe("content agent card", () => { }, ACTION_REGISTRY_TEST_TIMEOUT_MS, ); - - it( - "publishes bounded reads and message-only intake mutations for generic Dispatch delegation", - async () => { - const actions = await loadContentActions(); - const externalAgentOptions = { - connectorCatalog: [ - "list-content-databases", - "describe-content-database", - ], - }; - const skills = buildAuthenticatedAgentA2ASkills( - actions, - externalAgentOptions, - ); - const skillsById = new Map(skills.map((skill) => [skill.id, skill])); - - for (const actionName of [ - "list-content-databases", - "describe-content-database", - ]) { - expect(skillsById.get(actionName)).toMatchObject({ readOnly: true }); - expect(skillsById.get(actionName)?.inputSchema).toBeDefined(); - } - expect(skillsById.get("list-content-databases")?.description).toContain( - "user-authored description", - ); - - for (const actionName of [ - "submit-content-database-form", - "add-database-item", - "update-document", - "set-document-property", - ]) { - expect(skillsById.get(actionName)).toMatchObject({ readOnly: false }); - expect(skillsById.get(actionName)?.inputSchema).toBeUndefined(); - } - - const directlyInvocable = filterDirectA2AActions( - actions, - externalAgentOptions, - ); - expect(directlyInvocable).toHaveProperty("list-content-databases"); - expect(directlyInvocable).toHaveProperty("describe-content-database"); - expect(directlyInvocable).not.toHaveProperty("get-content-database"); - expect(directlyInvocable).not.toHaveProperty( - "submit-content-database-form", - ); - expect(directlyInvocable).not.toHaveProperty("update-document"); - - const intakeHint = dispatchIntegrationRoutingHint( - "Add this design request to the editorial intake database", - ); - expect(intakeHint?.targetAgent).toBeUndefined(); - expect(intakeHint?.instruction).toContain("discovered app capabilities"); - - expect( - dispatchIntegrationRoutingHint( - "Design a visual mockup for the editorial intake screen", - ), - ).toMatchObject({ targetAgent: "design" }); - }, - ACTION_REGISTRY_TEST_TIMEOUT_MS, - ); }); From 752ce64369b396b88ff90b772d8eef1dd9f48861 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:29:56 -0400 Subject: [PATCH 3/8] chore: add Content intake capability changeset --- .changeset/publish-content-intake-capabilities.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/publish-content-intake-capabilities.md diff --git a/.changeset/publish-content-intake-capabilities.md b/.changeset/publish-content-intake-capabilities.md new file mode 100644 index 0000000000..f0d30d0c6e --- /dev/null +++ b/.changeset/publish-content-intake-capabilities.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Publish delegated Content database intake capabilities through A2A discovery. From 362ec2e3e8e20ed8020a95b72a7bda3615af5a8f Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:32:03 -0400 Subject: [PATCH 4/8] Bound Content database discovery reads --- .../actions/list-content-databases.db.test.ts | 21 ++++++++++++++++++- .../content/actions/list-content-databases.ts | 17 ++++++++++----- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/templates/content/actions/list-content-databases.db.test.ts b/templates/content/actions/list-content-databases.db.test.ts index caa77ea5eb..b58f55e8c3 100644 --- a/templates/content/actions/list-content-databases.db.test.ts +++ b/templates/content/actions/list-content-databases.db.test.ts @@ -202,7 +202,7 @@ describe("list-content-databases", () => { listContentDatabasesAction.run({ title: "Missing Intake" }), ).rejects.toThrow(/No accessible Content database matched/); await expect( - listContentDatabasesAction.run({ title: "Shared Intake" }), + listContentDatabasesAction.run({ title: "Shared Intake", limit: 1 }), ).rejects.toThrow(/ambiguous across 2 accessible Content databases/); await expect( listContentDatabasesAction.run({ title: " " }), @@ -210,6 +210,25 @@ describe("list-content-databases", () => { }); }); + it("bounds ordinary discovery results", async () => { + await createDatabaseDocument({ + documentId: "db-doc-bounded-a", + databaseId: "db-bounded-a", + title: "Bounded Intake A", + }); + await createDatabaseDocument({ + documentId: "db-doc-bounded-b", + databaseId: "db-bounded-b", + title: "Bounded Intake B", + }); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + await expect( + listContentDatabasesAction.run({ query: "Bounded Intake", limit: 1 }), + ).resolves.toMatchObject({ databases: [{ databaseId: "db-bounded-a" }] }); + }); + }); + it("does not disclose system or inaccessible databases", async () => { await createDatabaseDocument({ documentId: "db-doc-system", diff --git a/templates/content/actions/list-content-databases.ts b/templates/content/actions/list-content-databases.ts index 2b736546de..5f41ec338e 100644 --- a/templates/content/actions/list-content-databases.ts +++ b/templates/content/actions/list-content-databases.ts @@ -64,6 +64,11 @@ export default defineAction({ const query = args.query?.trim(); const pattern = query ? `%${escapeLike(query.toLowerCase())}%` : null; const exactTitle = args.title?.toLowerCase(); + const resolvesExactly = !!( + args.databaseId || + args.documentId || + exactTitle + ); const excludedDatabaseIds = new Set( [ args.excludeDatabaseId?.trim(), @@ -120,7 +125,12 @@ export default defineAction({ ) .orderBy(asc(schema.documents.position)); - const rows = await queryBuilder; + // Exact resolution must inspect every match so ambiguity fails closed. + // Ordinary discovery keeps its caller-provided database bound. + const rows = + resolvesExactly || !args.limit + ? await queryBuilder + : await queryBuilder.limit(args.limit); const localTableSources = excludedDatabaseIds.size > 0 @@ -158,10 +168,7 @@ export default defineAction({ !sourceChainIncludesExcludedDatabase(row.id), ); - if ( - (args.databaseId || args.documentId || exactTitle) && - visibleRows.length !== 1 - ) { + if (resolvesExactly && visibleRows.length !== 1) { const selector = args.databaseId ? `database ID "${args.databaseId}"` : args.documentId From f15bd8cba6d48f9d65baedbfe419e44132d1eaca Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:52:19 -0400 Subject: [PATCH 5/8] Harden Content database discovery boundaries --- .../actions/describe-content-database.ts | 50 ++++++++++++++----- .../actions/list-content-databases.db.test.ts | 26 +++++++++- .../content/actions/list-content-databases.ts | 8 ++- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/templates/content/actions/describe-content-database.ts b/templates/content/actions/describe-content-database.ts index ba32eb9032..67954c24f3 100644 --- a/templates/content/actions/describe-content-database.ts +++ b/templates/content/actions/describe-content-database.ts @@ -1,15 +1,22 @@ import { defineAction } from "@agent-native/core"; +import { accessFilter } from "@agent-native/core/sharing"; +import { and, eq } from "drizzle-orm"; import { z } from "zod"; +import { getDb, schema } from "../server/db/index.js"; +import { getDocumentContextPath } from "../server/lib/document-context.js"; import type { ContentDatabaseDescriptionResponse, ContentDatabaseUnavailableResponse, } from "../shared/api.js"; +import { resolveContentDatabaseRead } from "./_database-utils.js"; import { - getContentDatabaseResponse, - resolveContentDatabaseRead, -} from "./_database-utils.js"; -import listContentDatabases from "./list-content-databases.js"; + listPropertiesForDatabase, + serializeDatabase, +} from "./_property-utils.js"; +import listContentDatabases, { + ContentDatabaseResolutionError, +} from "./list-content-databases.js"; export default defineAction({ description: @@ -39,7 +46,8 @@ export default defineAction({ let selection: Awaited>; try { selection = await listContentDatabases.run({ databaseId, documentId }); - } catch { + } catch (error) { + if (!(error instanceof ContentDatabaseResolutionError)) throw error; throw new Error("Content database not found."); } const selected = selection.databases[0]; @@ -53,14 +61,32 @@ export default defineAction({ throw new Error("Content database not found."); } - const response = await getContentDatabaseResponse(resolved.database.id, { - limit: 1, - database: resolved.database, - }); + const db = getDb(); + const [databaseDocument] = await db + .select({ + id: schema.documents.id, + parentId: schema.documents.parentId, + }) + .from(schema.documents) + .where( + and( + eq(schema.documents.id, selected.documentId), + accessFilter(schema.documents, schema.documentShares), + ), + ); + if (!databaseDocument) throw new Error("Content database not found."); + + const [properties, contextPath] = await Promise.all([ + listPropertiesForDatabase(resolved.database.id), + getDocumentContextPath(databaseDocument), + ]); return { - database: response.database, - contextPath: response.contextPath ?? [], - properties: response.properties, + database: serializeDatabase( + { ...resolved.database, title: selected.title }, + selected.description, + ), + contextPath, + properties, }; }, }); diff --git a/templates/content/actions/list-content-databases.db.test.ts b/templates/content/actions/list-content-databases.db.test.ts index b58f55e8c3..611a46d3e6 100644 --- a/templates/content/actions/list-content-databases.db.test.ts +++ b/templates/content/actions/list-content-databases.db.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { runWithRequestContext } from "@agent-native/core/server"; import { eq } from "drizzle-orm"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; const TEST_DB_PATH = join( tmpdir(), @@ -46,6 +46,7 @@ async function createDatabaseDocument(args: { description?: string; spaceId?: string; systemRole?: string; + hideFromSearch?: boolean; ownerEmail?: string; }) { const db = getDb(); @@ -59,6 +60,7 @@ async function createDatabaseDocument(args: { title: args.title, content: "", description: args.description ?? "", + hideFromSearch: args.hideFromSearch ? 1 : 0, position: 1, visibility: "private", createdAt: now, @@ -242,6 +244,12 @@ describe("list-content-databases", () => { title: "Private Other", ownerEmail: "other@example.com", }); + await createDatabaseDocument({ + documentId: "db-doc-hidden", + databaseId: "db-hidden", + title: "Hidden Intake", + hideFromSearch: true, + }); await runWithRequestContext({ userEmail: OWNER }, async () => { await expect( @@ -250,6 +258,9 @@ describe("list-content-databases", () => { await expect( listContentDatabasesAction.run({ databaseId: "db-private-other" }), ).rejects.toThrow(/No accessible Content database matched/); + await expect( + listContentDatabasesAction.run({ databaseId: "db-hidden" }), + ).rejects.toThrow(/No accessible Content database matched/); await expect( describeContentDatabaseAction.run({ databaseId: "db-system" }), ).rejects.toThrow("Content database not found."); @@ -272,6 +283,19 @@ describe("list-content-databases", () => { }); }); + it("preserves unexpected database discovery failures", async () => { + const discovery = vi + .spyOn(listContentDatabasesAction, "run") + .mockRejectedValueOnce(new Error("database unavailable")); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + await expect( + describeContentDatabaseAction.run({ databaseId: "db-exact" }), + ).rejects.toThrow("database unavailable"); + }); + discovery.mockRestore(); + }); + it("excludes a database when its document id is passed (no source attached yet)", async () => { await createDatabaseDocument({ documentId: "db-doc-self", diff --git a/templates/content/actions/list-content-databases.ts b/templates/content/actions/list-content-databases.ts index 5f41ec338e..c9aa8856ef 100644 --- a/templates/content/actions/list-content-databases.ts +++ b/templates/content/actions/list-content-databases.ts @@ -11,6 +11,8 @@ function escapeLike(s: string): string { return s.replace(/([\\%_])/g, "\\$1"); } +export class ContentDatabaseResolutionError extends Error {} + export default defineAction({ description: "Discover ordinary Content databases the user can access from their live title and user-authored description. Returns stable database, document, and space IDs. Use exact filters before reading a selected database's schema.", @@ -95,6 +97,10 @@ export default defineAction({ accessFilter(schema.documents, schema.documentShares), isNull(schema.documents.trashedAt), documentDiscoveryFilter(), + or( + eq(schema.documents.hideFromSearch, 0), + isNull(schema.documents.hideFromSearch), + ), isNull(schema.contentDatabases.deletedAt), isNull(schema.contentDatabases.systemRole), args.spaceId @@ -174,7 +180,7 @@ export default defineAction({ : args.documentId ? `document ID "${args.documentId}"` : `title "${args.title?.trim()}"`; - throw new Error( + throw new ContentDatabaseResolutionError( visibleRows.length === 0 ? `No accessible Content database matched exact ${selector}.` : `Exact ${selector} is ambiguous across ${visibleRows.length} accessible Content databases.`, From e45333a819a5ef15a2d55a0230d25d4c02a84aff Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:00:44 -0400 Subject: [PATCH 6/8] Bound default Content database discovery --- .../actions/list-content-databases.db.test.ts | 16 +++++++++ .../content/actions/list-content-databases.ts | 33 +++++++++---------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/templates/content/actions/list-content-databases.db.test.ts b/templates/content/actions/list-content-databases.db.test.ts index 611a46d3e6..affe8e1a87 100644 --- a/templates/content/actions/list-content-databases.db.test.ts +++ b/templates/content/actions/list-content-databases.db.test.ts @@ -231,6 +231,21 @@ describe("list-content-databases", () => { }); }); + it("applies a default bound to ordinary discovery", async () => { + for (let index = 0; index < 51; index += 1) { + await createDatabaseDocument({ + documentId: `db-doc-default-bound-${index}`, + databaseId: `db-default-bound-${index}`, + title: `Default Bound ${index}`, + }); + } + + await runWithRequestContext({ userEmail: OWNER }, async () => { + const result = await listContentDatabasesAction.run({}); + expect(result.databases).toHaveLength(50); + }); + }); + it("does not disclose system or inaccessible databases", async () => { await createDatabaseDocument({ documentId: "db-doc-system", @@ -311,6 +326,7 @@ describe("list-content-databases", () => { await runWithRequestContext({ userEmail: OWNER }, async () => { const result = await listContentDatabasesAction.run({ excludeDatabaseIds: ["db-doc-self"], + query: "Other", }); expect( diff --git a/templates/content/actions/list-content-databases.ts b/templates/content/actions/list-content-databases.ts index c9aa8856ef..4d207afc1c 100644 --- a/templates/content/actions/list-content-databases.ts +++ b/templates/content/actions/list-content-databases.ts @@ -7,6 +7,8 @@ import { getDb, schema } from "../server/db/index.js"; import { documentDiscoveryFilter } from "../server/lib/documents.js"; import type { ListContentDatabasesResponse } from "../shared/api.js"; +const DEFAULT_CONTENT_DATABASE_DISCOVERY_LIMIT = 50; + function escapeLike(s: string): string { return s.replace(/([\\%_])/g, "\\$1"); } @@ -55,8 +57,8 @@ export default defineAction({ .int() .min(1) .max(50) - .optional() - .describe("Maximum number of databases to return."), + .default(DEFAULT_CONTENT_DATABASE_DISCOVERY_LIMIT) + .describe("Maximum number of databases to return. Defaults to 50."), }), http: { method: "GET" }, readOnly: true, @@ -133,10 +135,9 @@ export default defineAction({ // Exact resolution must inspect every match so ambiguity fails closed. // Ordinary discovery keeps its caller-provided database bound. - const rows = - resolvesExactly || !args.limit - ? await queryBuilder - : await queryBuilder.limit(args.limit); + const rows = resolvesExactly + ? await queryBuilder + : await queryBuilder.limit(args.limit); const localTableSources = excludedDatabaseIds.size > 0 @@ -187,17 +188,15 @@ export default defineAction({ ); } - const databases = visibleRows - .slice(0, args.limit ?? visibleRows.length) - .map((row) => ({ - databaseId: row.id, - documentId: row.documentId, - spaceId: row.spaceId, - // The document's live title (matches the sidebar) rather than the - // possibly-stale content_databases.title. - title: row.title ?? "Untitled database", - description: row.description, - })); + const databases = visibleRows.slice(0, args.limit).map((row) => ({ + databaseId: row.id, + documentId: row.documentId, + spaceId: row.spaceId, + // The document's live title (matches the sidebar) rather than the + // possibly-stale content_databases.title. + title: row.title ?? "Untitled database", + description: row.description, + })); return { databases }; }, From 45a17e624ffc77eaf2762f2af7820cd431bc3a62 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:34:15 -0400 Subject: [PATCH 7/8] fix(content): paginate database discovery --- .../actions/list-content-databases.db.test.ts | 98 ++++++- .../content/actions/list-content-databases.ts | 246 +++++++++++------- .../app/hooks/use-content-database.test.ts | 54 ++++ .../content/app/hooks/use-content-database.ts | 102 +++++++- templates/content/shared/api.ts | 1 + 5 files changed, 398 insertions(+), 103 deletions(-) diff --git a/templates/content/actions/list-content-databases.db.test.ts b/templates/content/actions/list-content-databases.db.test.ts index affe8e1a87..4a8f7f2f70 100644 --- a/templates/content/actions/list-content-databases.db.test.ts +++ b/templates/content/actions/list-content-databases.db.test.ts @@ -97,6 +97,14 @@ describe("list-content-databases", () => { description: "", }, ], + pagination: { + offset: 0, + limit: 6, + totalItems: 1, + returnedItems: 1, + hasMore: false, + nextOffset: null, + }, }); }); }); @@ -127,6 +135,14 @@ describe("list-content-databases", () => { description: "Collects requests for editorial design review", }, ], + pagination: { + offset: 0, + limit: 50, + totalItems: 1, + returnedItems: 1, + hasMore: false, + nextOffset: null, + }, }); }); }); @@ -157,6 +173,14 @@ describe("list-content-databases", () => { description: "Captures product feedback", }, ], + pagination: { + offset: 0, + limit: 50, + totalItems: 1, + returnedItems: 1, + hasMore: false, + nextOffset: null, + }, }; await expect( listContentDatabasesAction.run({ databaseId: "db-exact" }), @@ -205,7 +229,9 @@ describe("list-content-databases", () => { ).rejects.toThrow(/No accessible Content database matched/); await expect( listContentDatabasesAction.run({ title: "Shared Intake", limit: 1 }), - ).rejects.toThrow(/ambiguous across 2 accessible Content databases/); + ).rejects.toThrow( + /ambiguous across multiple accessible Content databases/, + ); await expect( listContentDatabasesAction.run({ title: " " }), ).rejects.toThrow(); @@ -241,8 +267,76 @@ describe("list-content-databases", () => { } await runWithRequestContext({ userEmail: OWNER }, async () => { - const result = await listContentDatabasesAction.run({}); + const result = await listContentDatabasesAction.run({ + query: "Default Bound", + }); expect(result.databases).toHaveLength(50); + expect(result.pagination).toMatchObject({ + offset: 0, + limit: 50, + returnedItems: 50, + hasMore: true, + nextOffset: 50, + }); + + const continuation = await listContentDatabasesAction.run({ + query: "Default Bound", + offset: 50, + }); + expect(continuation.databases).toHaveLength(1); + expect(continuation.pagination).toMatchObject({ + offset: 50, + limit: 50, + returnedItems: 1, + hasMore: false, + nextOffset: null, + }); + }); + }); + + it("fills a bounded page after applying source-chain exclusions", async () => { + await createDatabaseDocument({ + documentId: "db-doc-fill-root", + databaseId: "db-fill-root", + title: "Fill Page Root", + }); + await createDatabaseDocument({ + documentId: "db-doc-fill-a-child", + databaseId: "db-fill-a-child", + title: "Fill Page Child", + }); + await createDatabaseDocument({ + documentId: "db-doc-fill-b-other", + databaseId: "db-fill-b-other", + title: "Fill Page Other", + }); + const now = new Date().toISOString(); + await getDb().insert(schema.contentDatabaseSources).values({ + id: "src-fill-child-root", + ownerEmail: OWNER, + databaseId: "db-fill-a-child", + sourceType: "local-table", + sourceName: "Root", + sourceTable: "db-fill-root", + createdAt: now, + updatedAt: now, + }); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + const result = await listContentDatabasesAction.run({ + excludeDatabaseIds: ["db-fill-root"], + query: "Fill Page", + limit: 1, + }); + + expect(result).toMatchObject({ + databases: [{ databaseId: "db-fill-b-other" }], + pagination: { + totalItems: 1, + returnedItems: 1, + hasMore: false, + }, + }); }); }); diff --git a/templates/content/actions/list-content-databases.ts b/templates/content/actions/list-content-databases.ts index 4d207afc1c..9d8a785f7b 100644 --- a/templates/content/actions/list-content-databases.ts +++ b/templates/content/actions/list-content-databases.ts @@ -1,11 +1,21 @@ import { defineAction } from "@agent-native/core"; import { accessFilter } from "@agent-native/core/sharing"; -import { and, asc, eq, isNull, ne, or, sql } from "drizzle-orm"; +import { + and, + asc, + eq, + inArray, + isNull, + notInArray, + or, + sql, +} from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { documentDiscoveryFilter } from "../server/lib/documents.js"; import type { ListContentDatabasesResponse } from "../shared/api.js"; +import { documentDiscoveryPagination } from "./_document-discovery-query.js"; const DEFAULT_CONTENT_DATABASE_DISCOVERY_LIMIT = 50; @@ -17,7 +27,7 @@ export class ContentDatabaseResolutionError extends Error {} export default defineAction({ description: - "Discover ordinary Content databases the user can access from their live title and user-authored description. Returns stable database, document, and space IDs. Use exact filters before reading a selected database's schema.", + "Discover one bounded page of ordinary Content databases the user can access from their live title and user-authored description. Returns stable database, document, and space IDs with explicit pagination; follow nextOffset until hasMore is false. Use exact filters before reading a selected database's schema.", schema: z.object({ spaceId: z .string() @@ -59,6 +69,12 @@ export default defineAction({ .max(50) .default(DEFAULT_CONTENT_DATABASE_DISCOVERY_LIMIT) .describe("Maximum number of databases to return. Defaults to 50."), + offset: z.coerce + .number() + .int() + .min(0) + .default(0) + .describe("Zero-based continuation offset."), }), http: { method: "GET" }, readOnly: true, @@ -79,66 +95,6 @@ export default defineAction({ ...(args.excludeDatabaseIds ?? []).map((id) => id.trim()), ].filter((id): id is string => !!id), ); - // The same access + discovery filter the sidebar uses, so the picker shows - // owned AND shared/org databases and never a trashed/hidden one. - const queryBuilder = db - .select({ - id: schema.contentDatabases.id, - documentId: schema.contentDatabases.documentId, - title: schema.documents.title, - description: schema.documents.description, - spaceId: schema.contentDatabases.spaceId, - }) - .from(schema.contentDatabases) - .innerJoin( - schema.documents, - eq(schema.contentDatabases.documentId, schema.documents.id), - ) - .where( - and( - accessFilter(schema.documents, schema.documentShares), - isNull(schema.documents.trashedAt), - documentDiscoveryFilter(), - or( - eq(schema.documents.hideFromSearch, 0), - isNull(schema.documents.hideFromSearch), - ), - isNull(schema.contentDatabases.deletedAt), - isNull(schema.contentDatabases.systemRole), - args.spaceId - ? eq(schema.contentDatabases.spaceId, args.spaceId) - : undefined, - args.databaseId - ? eq(schema.contentDatabases.id, args.databaseId) - : undefined, - args.documentId - ? eq(schema.contentDatabases.documentId, args.documentId) - : undefined, - exactTitle - ? sql`lower(${schema.documents.title}) = ${exactTitle}` - : undefined, - excludedDatabaseIds.size === 1 - ? ne( - schema.contentDatabases.id, - Array.from(excludedDatabaseIds)[0]!, - ) - : undefined, - pattern - ? or( - sql`lower(${schema.documents.title}) LIKE ${pattern} ESCAPE '\\'`, - sql`lower(${schema.documents.description}) LIKE ${pattern} ESCAPE '\\'`, - ) - : undefined, - ), - ) - .orderBy(asc(schema.documents.position)); - - // Exact resolution must inspect every match so ambiguity fails closed. - // Ordinary discovery keeps its caller-provided database bound. - const rows = resolvesExactly - ? await queryBuilder - : await queryBuilder.limit(args.limit); - const localTableSources = excludedDatabaseIds.size > 0 ? await db @@ -149,46 +105,133 @@ export default defineAction({ .from(schema.contentDatabaseSources) .where(eq(schema.contentDatabaseSources.sourceType, "local-table")) : []; - const localTableTargetByDatabaseId = new Map( - localTableSources.map((source) => [ - source.databaseId, - source.sourceTable, - ]), - ); - const sourceChainIncludesExcludedDatabase = (databaseId: string) => { - const seen = new Set(); - let current: string | undefined = databaseId; - while (current && !seen.has(current)) { - if (excludedDatabaseIds.has(current)) return true; - seen.add(current); - current = localTableTargetByDatabaseId.get(current); + const excludedDatabaseRows = + excludedDatabaseIds.size > 0 + ? await db + .select({ id: schema.contentDatabases.id }) + .from(schema.contentDatabases) + .innerJoin( + schema.documents, + eq(schema.contentDatabases.documentId, schema.documents.id), + ) + .where( + and( + accessFilter(schema.documents, schema.documentShares), + or( + inArray( + schema.contentDatabases.id, + Array.from(excludedDatabaseIds), + ), + inArray( + schema.contentDatabases.documentId, + Array.from(excludedDatabaseIds), + ), + ), + ), + ) + : []; + const excludedSourceChainDatabaseIds = new Set([ + ...excludedDatabaseIds, + ...excludedDatabaseRows.map((row) => row.id), + ]); + let expandedSourceChain = true; + while (expandedSourceChain) { + expandedSourceChain = false; + for (const source of localTableSources) { + if ( + excludedSourceChainDatabaseIds.has(source.sourceTable) && + !excludedSourceChainDatabaseIds.has(source.databaseId) + ) { + excludedSourceChainDatabaseIds.add(source.databaseId); + expandedSourceChain = true; + } } - return false; - }; + } - const visibleRows = rows - // Exclusion ids may be database ids OR database document ids — the - // settings panel only has the document id before any source exists. - .filter( - (row) => - !excludedDatabaseIds.has(row.documentId) && - !sourceChainIncludesExcludedDatabase(row.id), - ); + // The same access + discovery filter the sidebar uses, so the picker shows + // owned AND shared/org databases and never a trashed/hidden one. Resolve + // source-chain exclusions before limiting so every page is truthfully full. + const where = and( + accessFilter(schema.documents, schema.documentShares), + isNull(schema.documents.trashedAt), + documentDiscoveryFilter(), + or( + eq(schema.documents.hideFromSearch, 0), + isNull(schema.documents.hideFromSearch), + ), + isNull(schema.contentDatabases.deletedAt), + isNull(schema.contentDatabases.systemRole), + args.spaceId + ? eq(schema.contentDatabases.spaceId, args.spaceId) + : undefined, + args.databaseId + ? eq(schema.contentDatabases.id, args.databaseId) + : undefined, + args.documentId + ? eq(schema.contentDatabases.documentId, args.documentId) + : undefined, + exactTitle + ? sql`lower(${schema.documents.title}) = ${exactTitle}` + : undefined, + excludedSourceChainDatabaseIds.size > 0 + ? notInArray( + schema.contentDatabases.id, + Array.from(excludedSourceChainDatabaseIds), + ) + : undefined, + excludedDatabaseIds.size > 0 + ? notInArray( + schema.contentDatabases.documentId, + Array.from(excludedDatabaseIds), + ) + : undefined, + pattern + ? or( + sql`lower(${schema.documents.title}) LIKE ${pattern} ESCAPE '\\'`, + sql`lower(${schema.documents.description}) LIKE ${pattern} ESCAPE '\\'`, + ) + : undefined, + ); + const baseQuery = () => + db + .select({ + id: schema.contentDatabases.id, + documentId: schema.contentDatabases.documentId, + title: schema.documents.title, + description: schema.documents.description, + spaceId: schema.contentDatabases.spaceId, + }) + .from(schema.contentDatabases) + .innerJoin( + schema.documents, + eq(schema.contentDatabases.documentId, schema.documents.id), + ) + .where(where) + .orderBy( + asc(schema.documents.position), + asc(schema.contentDatabases.id), + ); + + // Two visible matches are sufficient to reject an exact selector without + // materializing every duplicate-title row. + const rows = resolvesExactly + ? await baseQuery().limit(2) + : await baseQuery().limit(args.limit).offset(args.offset); - if (resolvesExactly && visibleRows.length !== 1) { + if (resolvesExactly && rows.length !== 1) { const selector = args.databaseId ? `database ID "${args.databaseId}"` : args.documentId ? `document ID "${args.documentId}"` : `title "${args.title?.trim()}"`; throw new ContentDatabaseResolutionError( - visibleRows.length === 0 + rows.length === 0 ? `No accessible Content database matched exact ${selector}.` - : `Exact ${selector} is ambiguous across ${visibleRows.length} accessible Content databases.`, + : `Exact ${selector} is ambiguous across multiple accessible Content databases.`, ); } - const databases = visibleRows.slice(0, args.limit).map((row) => ({ + const databases = rows.map((row) => ({ databaseId: row.id, documentId: row.documentId, spaceId: row.spaceId, @@ -198,6 +241,29 @@ export default defineAction({ description: row.description, })); - return { databases }; + const totalItems = resolvesExactly + ? databases.length + : Number( + ( + await db + .select({ count: sql`count(*)` }) + .from(schema.contentDatabases) + .innerJoin( + schema.documents, + eq(schema.contentDatabases.documentId, schema.documents.id), + ) + .where(where) + )[0]?.count ?? 0, + ); + + return { + databases, + pagination: documentDiscoveryPagination({ + offset: resolvesExactly ? 0 : args.offset, + limit: args.limit, + totalItems, + returnedItems: databases.length, + }), + }; }, }); diff --git a/templates/content/app/hooks/use-content-database.test.ts b/templates/content/app/hooks/use-content-database.test.ts index 5795561fbb..5222b00709 100644 --- a/templates/content/app/hooks/use-content-database.test.ts +++ b/templates/content/app/hooks/use-content-database.test.ts @@ -17,6 +17,7 @@ import { contentDatabaseResponseCanSeedQuery, contentDatabaseItemsPageQueryKey, contentDatabaseQueryKey, + fetchCompleteContentDatabaseList, invalidateBuilderBodyHydrationQueries, invalidateContentDatabaseSourceRefreshQueries, moveOptimisticContentDatabaseItem, @@ -30,6 +31,59 @@ import { const createdAt = "2026-06-15T12:00:00.000Z"; +describe("complete Content database discovery", () => { + it("exhausts every bounded page before returning source-picker options", async () => { + const databases = Array.from({ length: 101 }, (_, index) => ({ + databaseId: `database-${index}`, + documentId: `document-${index}`, + spaceId: null, + title: `Database ${index}`, + description: "", + })); + const offsets: number[] = []; + + const result = await fetchCompleteContentDatabaseList( + async (offset, limit) => { + offsets.push(offset); + const page = databases.slice(offset, offset + limit); + const nextOffset = offset + page.length; + return { + databases: page, + pagination: { + offset, + limit, + totalItems: databases.length, + returnedItems: page.length, + hasMore: nextOffset < databases.length, + nextOffset: nextOffset < databases.length ? nextOffset : null, + }, + }; + }, + ); + + expect(offsets).toEqual([0, 50, 100]); + expect(result.map((database) => database.databaseId)).toEqual( + databases.map((database) => database.databaseId), + ); + }); + + it("rejects a non-advancing continuation instead of clipping silently", async () => { + await expect( + fetchCompleteContentDatabaseList(async (_offset, limit) => ({ + databases: [], + pagination: { + offset: 0, + limit, + totalItems: 1, + returnedItems: 0, + hasMore: true, + nextOffset: 0, + }, + })), + ).rejects.toThrow("non-advancing continuation"); + }); +}); + describe("preserveScopedDatabasePlaceholder", () => { const previous = { database: "organization-files" }; diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index 24884eaa25..f32f35ab74 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -1,4 +1,5 @@ import { + callAction, useActionMutation, useActionQuery, } from "@agent-native/core/client/hooks"; @@ -56,7 +57,7 @@ import type { ValidateBuilderSourceExecutionRequest, } from "@shared/api"; import type { Query, QueryClient } from "@tanstack/react-query"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { documentQueryFilter } from "../lib/document-query"; @@ -1390,16 +1391,95 @@ export function useContentDatabases(args: { excludeDatabaseIds?: string[]; enabled: boolean; }) { - return useActionQuery( - "list-content-databases", - args.enabled - ? { - excludeDatabaseId: args.excludeDatabaseId ?? undefined, - excludeDatabaseIds: args.excludeDatabaseIds ?? undefined, - } - : undefined, - { enabled: args.enabled, retry: false }, - ); + const filters = { + excludeDatabaseId: args.excludeDatabaseId ?? undefined, + excludeDatabaseIds: args.excludeDatabaseIds ?? undefined, + }; + return useQuery({ + queryKey: ["action", "list-content-databases", filters], + queryFn: async ({ signal }) => ({ + databases: await fetchCompleteContentDatabaseList((offset, limit) => + callAction( + "list-content-databases", + { ...filters, offset, limit }, + { method: "GET", signal }, + ), + ), + }), + enabled: args.enabled, + retry: false, + }); +} + +const CONTENT_DATABASE_LIST_PAGE_SIZE = 50; + +export async function fetchCompleteContentDatabaseList( + fetchPage: ( + offset: number, + limit: number, + ) => Promise, +) { + const databases: ListContentDatabasesResponse["databases"] = []; + const databaseIds = new Set(); + let offset = 0; + let expectedTotal: number | null = null; + + while (true) { + const page = await fetchPage(offset, CONTENT_DATABASE_LIST_PAGE_SIZE); + const { pagination } = page; + if (!pagination) { + throw new Error( + "list-content-databases returned no pagination boundary; refusing to treat the result as complete.", + ); + } + if ( + pagination.offset !== offset || + pagination.limit !== CONTENT_DATABASE_LIST_PAGE_SIZE || + pagination.returnedItems !== page.databases.length + ) { + throw new Error( + "list-content-databases returned inconsistent pagination metadata; retry the complete read.", + ); + } + if (expectedTotal === null) expectedTotal = pagination.totalItems; + if (pagination.totalItems !== expectedTotal) { + throw new Error( + "Content databases changed during paginated discovery; retry the complete read.", + ); + } + for (const database of page.databases) { + if (databaseIds.has(database.databaseId)) { + throw new Error( + `list-content-databases repeated database "${database.databaseId}" across pages; refusing an ambiguous result.`, + ); + } + databaseIds.add(database.databaseId); + databases.push(database); + } + + const expectedNextOffset = offset + page.databases.length; + if (!pagination.hasMore) { + if ( + pagination.nextOffset !== null || + expectedNextOffset !== expectedTotal || + databases.length !== expectedTotal + ) { + throw new Error( + "list-content-databases claimed exhaustion before every declared database was returned.", + ); + } + return databases; + } + if ( + pagination.nextOffset !== expectedNextOffset || + pagination.nextOffset <= offset + ) { + throw new Error( + "list-content-databases returned a non-advancing continuation; refusing a clipped result.", + ); + } + offset = pagination.nextOffset; + } } export function useSuggestSourceJoinKey(args: { diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 38d922dba7..47f4f30134 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -1090,6 +1090,7 @@ export interface ContentDatabaseDescriptionResponse { export interface ListContentDatabasesResponse { databases: ContentDatabaseSummary[]; + pagination: DocumentDiscoveryPagination; } export interface TrashedContentDatabaseSummary { From 81847890d516303a29dcfbda424745810ed8f876 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:39:35 -0400 Subject: [PATCH 8/8] fix(content): distinguish source discovery errors --- .../app/components/editor/database/DatabaseView.tsx | 7 +++++++ .../content/app/components/editor/database/settings.tsx | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index fda3ce3cb6..6b58a3afbb 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -113,6 +113,7 @@ import { import { Link, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; +import { QueryErrorState } from "@/components/QueryErrorState"; import { contentSpaceForCatalogItem, createContentSpaceSelectionQueue, @@ -9265,6 +9266,12 @@ function AddSourceView({ {dbText("loadingTables")} + ) : query.isError ? ( + void query.refetch()} + retrying={query.isFetching} + /> ) : tables.length === 0 ? (
{dbText("noOtherDatabasesAvailableToAdd")} diff --git a/templates/content/app/components/editor/database/settings.tsx b/templates/content/app/components/editor/database/settings.tsx index a34afd0d24..828d8aba28 100644 --- a/templates/content/app/components/editor/database/settings.tsx +++ b/templates/content/app/components/editor/database/settings.tsx @@ -52,6 +52,7 @@ import { } from "react"; import { toast } from "sonner"; +import { QueryErrorState } from "@/components/QueryErrorState"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -1543,6 +1544,12 @@ function AddSourceView({ {dbText("loadingTables")}
+ ) : query.isError ? ( + void query.refetch()} + retrying={query.isFetching} + /> ) : tables.length === 0 ? (
{dbText("noOtherDatabasesAvailableToAdd")}