diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts index fdd71484b8..205db7b1f3 100644 --- a/src/sqlite/SqliteDatabaseInspection.ts +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -255,14 +255,29 @@ const readSchema = ( ); let selectedTable: SelectedTableFacts | undefined; const metadata = new Map>(); - // Names bound the minimum eventual schema output while metadata is collected. - // The full schema objects below account for them in the actual reply budget. + const selectedKey = + selectedName === undefined ? undefined : sqliteIdentifierKey(selectedName); + const unresolvedVirtualTables: { name: string; prefix: string }[] = []; + // Names bound eventual output only when the whole catalog is requested. + // Selected scope retains one table and at most one possible shadow parent; + // unrelated names are examined without retaining them or charging the reply. const metadataBudget = new OutputBudget(); for (const raw of db.prepare("PRAGMA main.table_list").iterate()) { const value = tableRow.parse(raw); - if (value.schema === "main") { + if (value.schema !== "main") continue; + if (selectedName === undefined) { metadataBudget.retain(value.name); metadata.set(value.name, value); + } else if (value.name === selectedName) { + metadata.set(value.name, value); + } + if (value.type === "virtual" && value.ncol === 0) { + const prefix = sqliteIdentifierKey(`${value.name}_`); + if ( + selectedKey === undefined || + (unresolvedVirtualTables.length === 0 && selectedKey.startsWith(prefix)) + ) + unresolvedVirtualTables.push({ name: value.name, prefix }); } } // A trigger stores its target as written in CREATE TRIGGER, which SQLite @@ -278,12 +293,6 @@ const readSchema = ( ); return foldedNames.get(sqliteIdentifierKey(written)) ?? written; }; - const unresolvedVirtualTables = [...metadata.values()] - .filter((value) => value.type === "virtual" && value.ncol === 0) - .map((value) => ({ - name: value.name, - prefix: sqliteIdentifierKey(`${value.name}_`), - })); const shadowParentOf = ( name: string, listed: z.output, diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts index 17af7d5519..f6a9adfc5f 100644 --- a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -235,6 +235,53 @@ it("counts schema names once when enforcing the complete reply budget", async () ); }); +it("keeps unrelated catalog names outside the selected-table reply budget", async () => { + const path = await createDatabase( + "CREATE TABLE selected(value TEXT); INSERT INTO selected VALUES ('retained');", + ); + const producer = new DatabaseSync(path); + try { + producer.exec("BEGIN"); + for (let index = 0; index < 60; index++) { + const name = `unrelated_${index}_${"x".repeat(300 * 1024)}`; + producer.exec(`CREATE TABLE "${name}" (value TEXT)`); + } + producer.exec("COMMIT"); + } finally { + producer.close(); + } + const selected = inspectSqliteDatabaseSnapshot(path, { + path, + table: "selected", + }); + expect(selected.schema).toMatchObject({ + scope: "selected_table", + completeness: "complete", + tables: [{ name: "selected" }], + indexes: [], + views: [], + triggers: [], + }); + expect(selected.schema.tables).toHaveLength(1); + expect(selected.rows).toMatchObject({ + returned_rows: 1, + values: [[{ type: "text", value: "retained" }]], + }); + expect(() => + inspectSqliteDatabaseSnapshot(path, { path, table: "absent" }), + ).toThrowError( + expect.objectContaining({ + reason: "selection", + message: + "Selected ordinary table does not exist with that exact name: absent", + }), + ); + for (const schema_scope of ["inventory", "database"] as const) + expect(() => + inspectSqliteDatabaseSnapshot(path, { path, schema_scope }), + ).toThrowError(expect.objectContaining({ reason: "output-limit" })); +}); + it("rejects malformed schema bytes instead of returning replacement text", async () => { const path = await createDatabase( "CREATE TABLE records(value TEXT /*schema-marker*/)",